diff --git a/api-extractor.json b/api-extractor.json index 6c599d255b4..66414503a29 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -352,6 +352,11 @@ // Needs investigation. "ae-forgotten-export": { "logLevel": "none" + }, + + // We don't prefix our internal APIs with underscores. + "ae-internal-missing-underscore": { + "logLevel": "none" } }, diff --git a/blocks/loops.ts b/blocks/loops.ts index dd5a8116211..6d450e53215 100644 --- a/blocks/loops.ts +++ b/blocks/loops.ts @@ -269,7 +269,7 @@ const CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN = { } const varField = this.getField('VAR') as FieldVariable; const variable = varField.getVariable()!; - const varName = variable.name; + const varName = variable.getName(); if (!this.isCollapsed() && varName !== null) { const getVarBlockState = { type: 'variables_get', diff --git a/blocks/procedures.ts b/blocks/procedures.ts index 20d8fa36bb0..534bfba6e82 100644 --- a/blocks/procedures.ts +++ b/blocks/procedures.ts @@ -9,7 +9,6 @@ import type {Block} from '../core/block.js'; import type {BlockSvg} from '../core/block_svg.js'; import type {BlockDefinition} from '../core/blocks.js'; -import * as common from '../core/common.js'; import {defineBlocks} from '../core/common.js'; import {config} from '../core/config.js'; import type {Connection} from '../core/connection.js'; @@ -27,15 +26,19 @@ import {FieldCheckbox} from '../core/field_checkbox.js'; import {FieldLabel} from '../core/field_label.js'; import * as fieldRegistry from '../core/field_registry.js'; import {FieldTextInput} from '../core/field_textinput.js'; +import {getFocusManager} from '../core/focus_manager.js'; import '../core/icons/comment_icon.js'; import {MutatorIcon as Mutator} from '../core/icons/mutator_icon.js'; import '../core/icons/warning_icon.js'; import {Align} from '../core/inputs/align.js'; +import type { + IVariableModel, + IVariableState, +} from '../core/interfaces/i_variable_model.js'; import {Msg} from '../core/msg.js'; import {Names} from '../core/names.js'; import * as Procedures from '../core/procedures.js'; import * as xmlUtils from '../core/utils/xml.js'; -import type {VariableModel} from '../core/variable_model.js'; import * as Variables from '../core/variables.js'; import type {Workspace} from '../core/workspace.js'; import type {WorkspaceSvg} from '../core/workspace_svg.js'; @@ -48,7 +51,7 @@ export const blocks: {[key: string]: BlockDefinition} = {}; type ProcedureBlock = Block & ProcedureMixin; interface ProcedureMixin extends ProcedureMixinType { arguments_: string[]; - argumentVarModels_: VariableModel[]; + argumentVarModels_: IVariableModel[]; callType_: string; paramIds_: string[]; hasStatements_: boolean; @@ -128,7 +131,7 @@ const PROCEDURE_DEF_COMMON = { for (let i = 0; i < this.argumentVarModels_.length; i++) { const parameter = xmlUtils.createElement('arg'); const argModel = this.argumentVarModels_[i]; - parameter.setAttribute('name', argModel.name); + parameter.setAttribute('name', argModel.getName()); parameter.setAttribute('varid', argModel.getId()); if (opt_paramIds && this.paramIds_) { parameter.setAttribute('paramId', this.paramIds_[i]); @@ -196,7 +199,7 @@ const PROCEDURE_DEF_COMMON = { state['params'].push({ // We don't need to serialize the name, but just in case we decide // to separate params from variables. - 'name': this.argumentVarModels_[i].name, + 'name': this.argumentVarModels_[i].getName(), 'id': this.argumentVarModels_[i].getId(), }); } @@ -224,7 +227,7 @@ const PROCEDURE_DEF_COMMON = { param['name'], '', ); - this.arguments_.push(variable.name); + this.arguments_.push(variable.getName()); this.argumentVarModels_.push(variable); } } @@ -352,7 +355,9 @@ const PROCEDURE_DEF_COMMON = { * * @returns List of variable models. */ - getVarModels: function (this: ProcedureBlock): VariableModel[] { + getVarModels: function ( + this: ProcedureBlock, + ): IVariableModel[] { return this.argumentVarModels_; }, /** @@ -370,23 +375,23 @@ const PROCEDURE_DEF_COMMON = { newId: string, ) { const oldVariable = this.workspace.getVariableById(oldId)!; - if (oldVariable.type !== '') { + if (oldVariable.getType() !== '') { // Procedure arguments always have the empty type. return; } - const oldName = oldVariable.name; + const oldName = oldVariable.getName(); const newVar = this.workspace.getVariableById(newId)!; let change = false; for (let i = 0; i < this.argumentVarModels_.length; i++) { if (this.argumentVarModels_[i].getId() === oldId) { - this.arguments_[i] = newVar.name; + this.arguments_[i] = newVar.getName(); this.argumentVarModels_[i] = newVar; change = true; } } if (change) { - this.displayRenamedVar_(oldName, newVar.name); + this.displayRenamedVar_(oldName, newVar.getName()); Procedures.mutateCallers(this); } }, @@ -398,9 +403,9 @@ const PROCEDURE_DEF_COMMON = { */ updateVarName: function ( this: ProcedureBlock & BlockSvg, - variable: VariableModel, + variable: IVariableModel, ) { - const newName = variable.name; + const newName = variable.getName(); let change = false; let oldName; for (let i = 0; i < this.argumentVarModels_.length; i++) { @@ -473,12 +478,16 @@ const PROCEDURE_DEF_COMMON = { const getVarBlockState = { type: 'variables_get', fields: { - VAR: {name: argVar.name, id: argVar.getId(), type: argVar.type}, + VAR: { + name: argVar.getName(), + id: argVar.getId(), + type: argVar.getType(), + }, }, }; options.push({ enabled: true, - text: Msg['VARIABLES_SET_CREATE_GET'].replace('%1', argVar.name), + text: Msg['VARIABLES_SET_CREATE_GET'].replace('%1', argVar.getName()), callback: ContextMenu.callbackFactory(this, getVarBlockState), }); } @@ -620,30 +629,49 @@ type ArgumentBlock = Block & ArgumentMixin; interface ArgumentMixin extends ArgumentMixinType {} type ArgumentMixinType = typeof PROCEDURES_MUTATORARGUMENT; -// TODO(#6920): This is kludgy. -type FieldTextInputForArgument = FieldTextInput & { - oldShowEditorFn_(_e?: Event, quietInput?: boolean): void; - createdVariables_: VariableModel[]; -}; +/** + * Field responsible for editing procedure argument names. + */ +class ProcedureArgumentField extends FieldTextInput { + /** + * Whether or not this field is currently being edited interactively. + */ + editingInteractively = false; + + /** + * The procedure argument variable whose name is being interactively edited. + */ + editingVariable?: IVariableModel; + + /** + * Displays the field editor. + * + * @param e The event that triggered display of the field editor. + */ + protected override showEditor_(e?: Event) { + super.showEditor_(e); + this.editingInteractively = true; + this.editingVariable = undefined; + } + + /** + * Handles cleanup when the field editor is dismissed. + */ + override onFinishEditing_(value: string) { + super.onFinishEditing_(value); + this.editingInteractively = false; + } +} const PROCEDURES_MUTATORARGUMENT = { /** * Mutator block for procedure argument. */ init: function (this: ArgumentBlock) { - const field = fieldRegistry.fromJson({ - type: 'field_input', - text: Procedures.DEFAULT_ARG, - }) as FieldTextInputForArgument; - field.setValidator(this.validator_); - // Hack: override showEditor to do just a little bit more work. - // We don't have a good place to hook into the start of a text edit. - field.oldShowEditorFn_ = (field as AnyDuringMigration).showEditor_; - const newShowEditorFn = function (this: typeof field) { - this.createdVariables_ = []; - this.oldShowEditorFn_(); - }; - (field as AnyDuringMigration).showEditor_ = newShowEditorFn; + const field = new ProcedureArgumentField( + Procedures.DEFAULT_ARG, + this.validator_, + ); this.appendDummyInput() .appendField(Msg['PROCEDURES_MUTATORARG_TITLE']) @@ -653,14 +681,6 @@ const PROCEDURES_MUTATORARGUMENT = { this.setStyle('procedure_blocks'); this.setTooltip(Msg['PROCEDURES_MUTATORARG_TOOLTIP']); this.contextMenu = false; - - // Create the default variable when we drag the block in from the flyout. - // Have to do this after installing the field on the block. - field.onFinishEditing_ = this.deleteIntermediateVars_; - // Create an empty list so onFinishEditing_ has something to look at, even - // though the editor was never opened. - field.createdVariables_ = []; - field.onFinishEditing_('x'); }, /** @@ -674,11 +694,11 @@ const PROCEDURES_MUTATORARGUMENT = { * @returns Valid name, or null if a name was not specified. */ validator_: function ( - this: FieldTextInputForArgument, + this: ProcedureArgumentField, varName: string, ): string | null { const sourceBlock = this.getSourceBlock()!; - const outerWs = sourceBlock!.workspace.getRootWorkspace()!; + const outerWs = sourceBlock.workspace.getRootWorkspace()!; varName = varName.replace(/[\s\xa0]+/g, ' ').replace(/^ | $/g, ''); if (!varName) { return null; @@ -707,50 +727,31 @@ const PROCEDURES_MUTATORARGUMENT = { return varName; } - let model = outerWs.getVariable(varName, ''); - if (model && model.name !== varName) { + const model = outerWs.getVariable(varName, ''); + if (model && model.getName() !== varName) { // Rename the variable (case change) outerWs.renameVariableById(model.getId(), varName); } if (!model) { - model = outerWs.createVariable(varName, ''); - if (model && this.createdVariables_) { - this.createdVariables_.push(model); + if (this.editingInteractively) { + if (!this.editingVariable) { + this.editingVariable = outerWs.createVariable(varName, ''); + } else { + outerWs.renameVariableById(this.editingVariable.getId(), varName); + } + } else { + outerWs.createVariable(varName, ''); } } return varName; }, - - /** - * Called when focusing away from the text field. - * Deletes all variables that were created as the user typed their intended - * variable name. - * - * @internal - * @param newText The new variable name. - */ - deleteIntermediateVars_: function ( - this: FieldTextInputForArgument, - newText: string, - ) { - const outerWs = this.getSourceBlock()!.workspace.getRootWorkspace(); - if (!outerWs) { - return; - } - for (let i = 0; i < this.createdVariables_.length; i++) { - const model = this.createdVariables_[i]; - if (model.name !== newText) { - outerWs.deleteVariableById(model.getId()); - } - } - }, }; blocks['procedures_mutatorarg'] = PROCEDURES_MUTATORARGUMENT; /** Type of a block using the PROCEDURE_CALL_COMMON mixin. */ type CallBlock = Block & CallMixin; interface CallMixin extends CallMixinType { - argumentVarModels_: VariableModel[]; + argumentVarModels_: IVariableModel[]; arguments_: string[]; defType_: string; quarkIds_: string[] | null; @@ -1029,7 +1030,7 @@ const PROCEDURE_CALL_COMMON = { * * @returns List of variable models. */ - getVarModels: function (this: CallBlock): VariableModel[] { + getVarModels: function (this: CallBlock): IVariableModel[] { return this.argumentVarModels_; }, /** @@ -1177,7 +1178,7 @@ const PROCEDURE_CALL_COMMON = { const def = Procedures.getDefinition(name, workspace); if (def) { (workspace as WorkspaceSvg).centerOnBlock(def.id); - common.setSelected(def as BlockSvg); + getFocusManager().focusNode(def as BlockSvg); } }, }); diff --git a/blocks/variables.ts b/blocks/variables.ts index 0ec9112a3d6..4f1f640fa81 100644 --- a/blocks/variables.ts +++ b/blocks/variables.ts @@ -21,7 +21,6 @@ import '../core/field_label.js'; import {FieldVariable} from '../core/field_variable.js'; import {Msg} from '../core/msg.js'; import * as Variables from '../core/variables.js'; -import type {WorkspaceSvg} from '../core/workspace_svg.js'; /** * A dictionary of the block definitions provided by this module. @@ -165,11 +164,11 @@ const deleteOptionCallbackFactory = function ( block: VariableBlock, ): () => void { return function () { - const workspace = block.workspace; const variableField = block.getField('VAR') as FieldVariable; - const variable = variableField.getVariable()!; - workspace.deleteVariableById(variable.getId()); - (workspace as WorkspaceSvg).refreshToolboxSelection(); + const variable = variableField.getVariable(); + if (variable) { + Variables.deleteVariable(variable.getWorkspace(), variable, block); + } }; }; diff --git a/blocks/variables_dynamic.ts b/blocks/variables_dynamic.ts index 8e4ce290e09..8afd24cf2e3 100644 --- a/blocks/variables_dynamic.ts +++ b/blocks/variables_dynamic.ts @@ -22,7 +22,6 @@ import '../core/field_label.js'; import {FieldVariable} from '../core/field_variable.js'; import {Msg} from '../core/msg.js'; import * as Variables from '../core/variables.js'; -import type {WorkspaceSvg} from '../core/workspace_svg.js'; /** * A dictionary of the block definitions provided by this module. @@ -144,9 +143,9 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { const id = this.getFieldValue('VAR'); const variableModel = Variables.getVariable(this.workspace, id)!; if (this.type === 'variables_get_dynamic') { - this.outputConnection!.setCheck(variableModel.type); + this.outputConnection!.setCheck(variableModel.getType()); } else { - this.getInput('VALUE')!.connection!.setCheck(variableModel.type); + this.getInput('VALUE')!.connection!.setCheck(variableModel.getType()); } }, }; @@ -176,11 +175,11 @@ const renameOptionCallbackFactory = function (block: VariableBlock) { */ const deleteOptionCallbackFactory = function (block: VariableBlock) { return function () { - const workspace = block.workspace; const variableField = block.getField('VAR') as FieldVariable; - const variable = variableField.getVariable()!; - workspace.deleteVariableById(variable.getId()); - (workspace as WorkspaceSvg).refreshToolboxSelection(); + const variable = variableField.getVariable(); + if (variable) { + Variables.deleteVariable(variable.getWorkspace(), variable, block); + } }; }; diff --git a/core/block.ts b/core/block.ts index 0face8f8c9b..43bc6bbc5ed 100644 --- a/core/block.ts +++ b/core/block.ts @@ -40,25 +40,26 @@ import {EndRowInput} from './inputs/end_row_input.js'; import {Input} from './inputs/input.js'; import {StatementInput} from './inputs/statement_input.js'; import {ValueInput} from './inputs/value_input.js'; -import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js'; import {isCommentIcon} from './interfaces/i_comment_icon.js'; import {type IIcon} from './interfaces/i_icon.js'; +import type { + IVariableModel, + IVariableState, +} from './interfaces/i_variable_model.js'; import * as registry from './registry.js'; import * as Tooltip from './tooltip.js'; import * as arrayUtils from './utils/array.js'; import {Coordinate} from './utils/coordinate.js'; -import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; -import type {VariableModel} from './variable_model.js'; import type {Workspace} from './workspace.js'; /** * Class for one block. * Not normally called directly, workspace.newBlock() is preferred. */ -export class Block implements IASTNodeLocation { +export class Block { /** * An optional callback method to use whenever the block's parent workspace * changes. This is usually only called from the constructor, the block type @@ -792,7 +793,7 @@ export class Block implements IASTNodeLocation { this.deletable && !this.shadow && !this.isDeadOrDying() && - !this.workspace.options.readOnly + !this.workspace.isReadOnly() ); } @@ -825,7 +826,7 @@ export class Block implements IASTNodeLocation { this.movable && !this.shadow && !this.isDeadOrDying() && - !this.workspace.options.readOnly + !this.workspace.isReadOnly() ); } @@ -914,7 +915,7 @@ export class Block implements IASTNodeLocation { */ isEditable(): boolean { return ( - this.editable && !this.isDeadOrDying() && !this.workspace.options.readOnly + this.editable && !this.isDeadOrDying() && !this.workspace.isReadOnly() ); } @@ -934,10 +935,8 @@ export class Block implements IASTNodeLocation { */ setEditable(editable: boolean) { this.editable = editable; - for (let i = 0, input; (input = this.inputList[i]); i++) { - for (let j = 0, field; (field = input.fieldRow[j]); j++) { - field.updateEditable(); - } + for (const field of this.getFields()) { + field.updateEditable(); } } @@ -1104,16 +1103,27 @@ export class Block implements IASTNodeLocation { ' instead', ); } - for (let i = 0, input; (input = this.inputList[i]); i++) { - for (let j = 0, field; (field = input.fieldRow[j]); j++) { - if (field.name === name) { - return field; - } + for (const field of this.getFields()) { + if (field.name === name) { + return field; } } return null; } + /** + * Returns a generator that provides every field on the block. + * + * @yields A generator that can be used to iterate the fields on the block. + */ + *getFields(): Generator { + for (const input of this.inputList) { + for (const field of input.fieldRow) { + yield field; + } + } + } + /** * Return all variables referenced by this block. * @@ -1121,12 +1131,9 @@ export class Block implements IASTNodeLocation { */ getVars(): string[] { const vars: string[] = []; - for (let i = 0, input; (input = this.inputList[i]); i++) { - for (let j = 0, field; (field = input.fieldRow[j]); j++) { - if (field.referencesVariables()) { - // NOTE: This only applies to `FieldVariable`, a `Field` - vars.push(field.getValue() as string); - } + for (const field of this.getFields()) { + if (field.referencesVariables()) { + vars.push(field.getValue()); } } return vars; @@ -1138,19 +1145,17 @@ export class Block implements IASTNodeLocation { * @returns List of variable models. * @internal */ - getVarModels(): VariableModel[] { + getVarModels(): IVariableModel[] { const vars = []; - for (let i = 0, input; (input = this.inputList[i]); i++) { - for (let j = 0, field; (field = input.fieldRow[j]); j++) { - if (field.referencesVariables()) { - const model = this.workspace.getVariableById( - field.getValue() as string, - ); - // Check if the variable actually exists (and isn't just a potential - // variable). - if (model) { - vars.push(model); - } + for (const field of this.getFields()) { + if (field.referencesVariables()) { + const model = this.workspace.getVariableById( + field.getValue() as string, + ); + // Check if the variable actually exists (and isn't just a potential + // variable). + if (model) { + vars.push(model); } } } @@ -1164,15 +1169,13 @@ export class Block implements IASTNodeLocation { * @param variable The variable being renamed. * @internal */ - updateVarName(variable: VariableModel) { - for (let i = 0, input; (input = this.inputList[i]); i++) { - for (let j = 0, field; (field = input.fieldRow[j]); j++) { - if ( - field.referencesVariables() && - variable.getId() === field.getValue() - ) { - field.refreshVariableName(); - } + updateVarName(variable: IVariableModel) { + for (const field of this.getFields()) { + if ( + field.referencesVariables() && + variable.getId() === field.getValue() + ) { + field.refreshVariableName(); } } } @@ -1186,11 +1189,9 @@ export class Block implements IASTNodeLocation { * updated name. */ renameVarById(oldId: string, newId: string) { - for (let i = 0, input; (input = this.inputList[i]); i++) { - for (let j = 0, field; (field = input.fieldRow[j]); j++) { - if (field.referencesVariables() && oldId === field.getValue()) { - field.setValue(newId); - } + for (const field of this.getFields()) { + if (field.referencesVariables() && oldId === field.getValue()) { + field.setValue(newId); } } } @@ -1408,48 +1409,6 @@ export class Block implements IASTNodeLocation { return this.disabledReasons.size === 0; } - /** @deprecated v11 - Get whether the block is manually disabled. */ - private get disabled(): boolean { - deprecation.warn( - 'disabled', - 'v11', - 'v12', - 'the isEnabled or hasDisabledReason methods of Block', - ); - return this.hasDisabledReason(constants.MANUALLY_DISABLED); - } - - /** @deprecated v11 - Set whether the block is manually disabled. */ - private set disabled(value: boolean) { - deprecation.warn( - 'disabled', - 'v11', - 'v12', - 'the setDisabledReason method of Block', - ); - this.setDisabledReason(value, constants.MANUALLY_DISABLED); - } - - /** - * @deprecated v11 - Set whether the block is manually enabled or disabled. - * The user can toggle whether a block is disabled from a context menu - * option. A block may still be disabled for other reasons even if the user - * attempts to manually enable it, such as when the block is in an invalid - * location. This method is deprecated and setDisabledReason should be used - * instead. - * - * @param enabled True if enabled. - */ - setEnabled(enabled: boolean) { - deprecation.warn( - 'setEnabled', - 'v11', - 'v12', - 'the setDisabledReason method of Block', - ); - this.setDisabledReason(!enabled, constants.MANUALLY_DISABLED); - } - /** * Add or remove a reason why the block might be disabled. If a block has * any reasons to be disabled, then the block itself will be considered @@ -2516,7 +2475,7 @@ export class Block implements IASTNodeLocation { * * Intended to on be used in console logs and errors. If you need a string * that uses the user's native language (including block text, field values, - * and child blocks), use [toString()]{@link Block#toString}. + * and child blocks), use {@link (Block:class).toString | toString()}. * * @returns The description. */ diff --git a/core/block_flyout_inflater.ts b/core/block_flyout_inflater.ts new file mode 100644 index 00000000000..80f86855182 --- /dev/null +++ b/core/block_flyout_inflater.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as common from './common.js'; +import {MANUALLY_DISABLED} from './constants.js'; +import type {Abstract as AbstractEvent} from './events/events_abstract.js'; +import {EventType} from './events/type.js'; +import {FlyoutItem} from './flyout_item.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import * as blocks from './serialization/blocks.js'; +import type {BlockInfo} from './utils/toolbox.js'; +import * as utilsXml from './utils/xml.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; +import * as Xml from './xml.js'; + +/** + * The language-neutral ID for when the reason why a block is disabled is + * because the workspace is at block capacity. + */ +const WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON = + 'WORKSPACE_AT_BLOCK_CAPACITY'; + +const BLOCK_TYPE = 'block'; + +/** + * Class responsible for creating blocks for flyouts. + */ +export class BlockFlyoutInflater implements IFlyoutInflater { + protected permanentlyDisabledBlocks = new Set(); + protected listeners = new Map(); + protected flyout?: IFlyout; + private capacityWrapper: (event: AbstractEvent) => void; + + /** + * Creates a new BlockFlyoutInflater instance. + */ + constructor() { + this.capacityWrapper = this.filterFlyoutBasedOnCapacity.bind(this); + } + + /** + * Inflates a flyout block from the given state and adds it to the flyout. + * + * @param state A JSON representation of a flyout block. + * @param flyout The flyout to create the block on. + * @returns A newly created block. + */ + load(state: object, flyout: IFlyout): FlyoutItem { + this.setFlyout(flyout); + const block = this.createBlock(state as BlockInfo, flyout.getWorkspace()); + + if (!block.isEnabled()) { + // Record blocks that were initially disabled. + // Do not enable these blocks as a result of capacity filtering. + this.permanentlyDisabledBlocks.add(block); + } else { + this.updateStateBasedOnCapacity(block); + } + + // Mark blocks as being inside a flyout. This is used to detect and + // prevent the closure of the flyout if the user right-clicks on such + // a block. + block.getDescendants(false).forEach((b) => (b.isInFlyout = true)); + this.addBlockListeners(block); + + return new FlyoutItem(block, BLOCK_TYPE); + } + + /** + * Creates a block on the given workspace. + * + * @param blockDefinition A JSON representation of the block to create. + * @param workspace The workspace to create the block on. + * @returns The newly created block. + */ + createBlock(blockDefinition: BlockInfo, workspace: WorkspaceSvg): BlockSvg { + let block; + if (blockDefinition['blockxml']) { + const xml = ( + typeof blockDefinition['blockxml'] === 'string' + ? utilsXml.textToDom(blockDefinition['blockxml']) + : blockDefinition['blockxml'] + ) as Element; + block = Xml.domToBlockInternal(xml, workspace); + } else { + if (blockDefinition['enabled'] === undefined) { + blockDefinition['enabled'] = + blockDefinition['disabled'] !== 'true' && + blockDefinition['disabled'] !== true; + } + if ( + blockDefinition['disabledReasons'] === undefined && + blockDefinition['enabled'] === false + ) { + blockDefinition['disabledReasons'] = [MANUALLY_DISABLED]; + } + // These fields used to be allowed and may still be present, but are + // ignored here since everything in the flyout should always be laid out + // linearly. + if ('x' in blockDefinition) { + delete blockDefinition['x']; + } + if ('y' in blockDefinition) { + delete blockDefinition['y']; + } + block = blocks.appendInternal(blockDefinition as blocks.State, workspace); + } + + return block as BlockSvg; + } + + /** + * Returns the amount of space that should follow this block. + * + * @param state A JSON representation of a flyout block. + * @param defaultGap The default spacing for flyout items. + * @returns The amount of space that should follow this block. + */ + gapForItem(state: object, defaultGap: number): number { + const blockState = state as BlockInfo; + let gap; + if (blockState['gap']) { + gap = parseInt(String(blockState['gap'])); + } else if (blockState['blockxml']) { + const xml = ( + typeof blockState['blockxml'] === 'string' + ? utilsXml.textToDom(blockState['blockxml']) + : blockState['blockxml'] + ) as Element; + gap = parseInt(xml.getAttribute('gap')!); + } + + return !gap || isNaN(gap) ? defaultGap : gap; + } + + /** + * Disposes of the given block. + * + * @param item The flyout block to dispose of. + */ + disposeItem(item: FlyoutItem): void { + const element = item.getElement(); + if (!(element instanceof BlockSvg)) return; + this.removeListeners(element.id); + element.dispose(false, false); + } + + /** + * Removes event listeners for the block with the given ID. + * + * @param blockId The ID of the block to remove event listeners from. + */ + protected removeListeners(blockId: string) { + const blockListeners = this.listeners.get(blockId) ?? []; + blockListeners.forEach((l) => browserEvents.unbind(l)); + this.listeners.delete(blockId); + } + + /** + * Updates this inflater's flyout. + * + * @param flyout The flyout that owns this inflater. + */ + protected setFlyout(flyout: IFlyout) { + if (this.flyout === flyout) return; + + if (this.flyout) { + this.flyout.targetWorkspace?.removeChangeListener(this.capacityWrapper); + } + this.flyout = flyout; + this.flyout.targetWorkspace?.addChangeListener(this.capacityWrapper); + } + + /** + * Updates the enabled state of the given block based on the capacity of the + * workspace. + * + * @param block The block to update the enabled/disabled state of. + */ + private updateStateBasedOnCapacity(block: BlockSvg) { + const enable = this.flyout?.targetWorkspace?.isCapacityAvailable( + common.getBlockTypeCounts(block), + ); + let currentBlock: BlockSvg | null = block; + while (currentBlock) { + currentBlock.setDisabledReason( + !enable, + WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON, + ); + currentBlock = currentBlock.getNextBlock(); + } + } + + /** + * Add listeners to a block that has been added to the flyout. + * + * @param block The block to add listeners for. + */ + protected addBlockListeners(block: BlockSvg) { + const blockListeners = []; + + blockListeners.push( + browserEvents.conditionalBind( + block.getSvgRoot(), + 'pointerdown', + block, + (e: PointerEvent) => { + const gesture = this.flyout?.targetWorkspace?.getGesture(e); + if (gesture && this.flyout) { + gesture.setStartBlock(block); + gesture.handleFlyoutStart(e, this.flyout); + } + }, + ), + ); + + blockListeners.push( + browserEvents.bind(block.getSvgRoot(), 'pointermove', null, () => { + if (!this.flyout?.targetWorkspace?.isDragging()) { + block.addSelect(); + } + }), + ); + blockListeners.push( + browserEvents.bind(block.getSvgRoot(), 'pointerleave', null, () => { + if (!this.flyout?.targetWorkspace?.isDragging()) { + block.removeSelect(); + } + }), + ); + + this.listeners.set(block.id, blockListeners); + } + + /** + * Updates the state of blocks in our owning flyout to be disabled/enabled + * based on the capacity of the workspace for more blocks of that type. + * + * @param event The event that triggered this update. + */ + private filterFlyoutBasedOnCapacity(event: AbstractEvent) { + if ( + !this.flyout || + (event && + !( + event.type === EventType.BLOCK_CREATE || + event.type === EventType.BLOCK_DELETE + )) + ) + return; + + this.flyout + .getWorkspace() + .getTopBlocks(false) + .forEach((block) => { + if (!this.permanentlyDisabledBlocks.has(block)) { + this.updateStateBasedOnCapacity(block); + } + }); + } + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return BLOCK_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + BLOCK_TYPE, + BlockFlyoutInflater, +); diff --git a/core/block_svg.ts b/core/block_svg.ts index 8e016efb32f..ea5dd7da7ed 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -16,7 +16,6 @@ import './events/events_selected.js'; import {Block} from './block.js'; import * as blockAnimations from './block_animations.js'; -import {IDeletable} from './blockly.js'; import * as browserEvents from './browser_events.js'; import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; import * as common from './common.js'; @@ -34,21 +33,21 @@ import {BlockDragStrategy} from './dragging/block_drag_strategy.js'; import type {BlockMove} from './events/events_block_move.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; -import type {Field} from './field.js'; import {FieldLabel} from './field_label.js'; +import {getFocusManager} from './focus_manager.js'; import {IconType} from './icons/icon_types.js'; import {MutatorIcon} from './icons/mutator_icon.js'; import {WarningIcon} from './icons/warning_icon.js'; import type {Input} from './inputs/input.js'; -import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {ICopyable} from './interfaces/i_copyable.js'; +import {IDeletable} from './interfaces/i_deletable.js'; import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {IIcon} from './interfaces/i_icon.js'; import * as internalConstants from './internal_constants.js'; -import {ASTNode} from './keyboard_nav/ast_node.js'; -import {TabNavigateCursor} from './keyboard_nav/tab_navigate_cursor.js'; -import {MarkerManager} from './marker_manager.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import {RenderedConnection} from './rendered_connection.js'; @@ -56,8 +55,8 @@ import type {IPathObject} from './renderers/common/i_path_object.js'; import * as blocks from './serialization/blocks.js'; import type {BlockStyle} from './theme.js'; import * as Tooltip from './tooltip.js'; +import {idGenerator} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; -import * as deprecation from './utils/deprecation.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; import {Svg} from './utils/svg.js'; @@ -73,11 +72,12 @@ import type {WorkspaceSvg} from './workspace_svg.js'; export class BlockSvg extends Block implements - IASTNodeLocationSvg, IBoundedElement, + IContextMenu, ICopyable, IDraggable, - IDeletable + IDeletable, + IFocusableNode { /** * Constant for identifying rows that are to be rendered inline. @@ -194,6 +194,9 @@ export class BlockSvg this.workspace = workspace; this.svgGroup = dom.createSvgElement(Svg.G, {}); + if (prototypeName) { + dom.addClass(this.svgGroup, prototypeName); + } /** A block style object. */ this.style = workspace.getRenderer().getConstants().getBlockStyle(null); @@ -209,6 +212,9 @@ export class BlockSvg // Expose this block's ID on its top-level SVG group. this.svgGroup.setAttribute('data-id', this.id); + // The page-wide unique ID of this Block used for focusing. + svgPath.id = idGenerator.getNextUniqueId(); + this.doInit_(); } @@ -228,7 +234,7 @@ export class BlockSvg this.applyColour(); this.pathObject.updateMovable(this.isMovable() || this.isInFlyout); const svg = this.getSvgRoot(); - if (!this.workspace.options.readOnly && svg) { + if (svg) { browserEvents.conditionalBind(svg, 'pointerdown', this, this.onMouseDown); } @@ -258,20 +264,14 @@ export class BlockSvg /** Selects this block. Highlights the block visually. */ select() { - if (this.isShadow()) { - this.getParent()?.select(); - return; - } this.addSelect(); + common.fireSelectedEvent(this); } /** Unselects this block. Unhighlights the block visually. */ unselect() { - if (this.isShadow()) { - this.getParent()?.unselect(); - return; - } this.removeSelect(); + common.fireSelectedEvent(null); } /** @@ -303,14 +303,22 @@ export class BlockSvg (newParent as BlockSvg).getSvgRoot().appendChild(svgRoot); } else if (oldParent) { // If we are losing a parent, we want to move our DOM element to the - // root of the workspace. - const draggingBlock = this.workspace + // root of the workspace. Try to insert it before any top-level + // block being dragged, but note that blocks can have the + // blocklyDragging class even if they're not top blocks (especially + // at start and end of a drag). + const draggingBlockElement = this.workspace .getCanvas() .querySelector('.blocklyDragging'); - if (draggingBlock) { - this.workspace.getCanvas().insertBefore(svgRoot, draggingBlock); + const draggingParentElement = draggingBlockElement?.parentElement as + | SVGElement + | null + | undefined; + const canvas = this.workspace.getCanvas(); + if (draggingParentElement === canvas) { + canvas.insertBefore(svgRoot, draggingBlockElement); } else { - this.workspace.getCanvas().appendChild(svgRoot); + canvas.appendChild(svgRoot); } this.translate(oldXY.x, oldXY.y); } @@ -544,10 +552,12 @@ export class BlockSvg if (!collapsed) { this.updateDisabled(); this.removeInput(collapsedInputName); + dom.removeClass(this.svgGroup, 'blocklyCollapsed'); this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID); return; } + dom.addClass(this.svgGroup, 'blocklyCollapsed'); if (this.childHasWarning()) { this.setWarningText( Msg['COLLAPSED_WARNINGS_WARNING'], @@ -567,41 +577,14 @@ export class BlockSvg input.appendField(new FieldLabel(text), collapsedFieldName); } - /** - * Open the next (or previous) FieldTextInput. - * - * @param start Current field. - * @param forward If true go forward, otherwise backward. - */ - tab(start: Field, forward: boolean) { - const tabCursor = new TabNavigateCursor(); - tabCursor.setCurNode(ASTNode.createFieldNode(start)!); - const currentNode = tabCursor.getCurNode(); - - if (forward) { - tabCursor.next(); - } else { - tabCursor.prev(); - } - - const nextNode = tabCursor.getCurNode(); - if (nextNode && nextNode !== currentNode) { - const nextField = nextNode.getLocation() as Field; - nextField.showEditor(); - - // Also move the cursor if we're in keyboard nav mode. - if (this.workspace.keyboardAccessibilityMode) { - this.workspace.getCursor()!.setCurNode(nextNode); - } - } - } - /** * Handle a pointerdown on an SVG block. * * @param e Pointer down event. */ private onMouseDown(e: PointerEvent) { + if (this.workspace.isReadOnly()) return; + const gesture = this.workspace.getGesture(e); if (gesture) { gesture.handleBlockStart(e, this); @@ -626,15 +609,15 @@ export class BlockSvg * * @returns Context menu options or null if no menu. */ - protected generateContextMenu(): Array< - ContextMenuOption | LegacyContextMenuOption - > | null { - if (this.workspace.options.readOnly || !this.contextMenu) { + protected generateContextMenu( + e: Event, + ): Array | null { + if (this.workspace.isReadOnly() || !this.contextMenu) { return null; } const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( - ContextMenuRegistry.ScopeType.BLOCK, - {block: this}, + {block: this, focusedNode: this}, + e, ); // Allow the block to add or modify menuOptions. @@ -645,17 +628,57 @@ export class BlockSvg return menuOptions; } + /** + * Gets the location in which to show the context menu for this block. + * Use the location of a click if the block was clicked, or a location + * based on the block's fields otherwise. + */ + protected calculateContextMenuLocation(e: Event): Coordinate { + // Open the menu where the user clicked, if they clicked + if (e instanceof PointerEvent) { + return new Coordinate(e.clientX, e.clientY); + } + + // Otherwise, calculate a location. + // Get the location of the top-left corner of the block in + // screen coordinates. + const blockCoords = svgMath.wsToScreenCoordinates( + this.workspace, + this.getRelativeToSurfaceXY(), + ); + + // Prefer a y position below the first field in the block. + const fieldBoundingClientRect = this.inputList + .filter((input) => input.isVisible()) + .flatMap((input) => input.fieldRow) + .find((f) => f.isVisible()) + ?.getSvgRoot() + ?.getBoundingClientRect(); + + const y = + fieldBoundingClientRect && fieldBoundingClientRect.height + ? fieldBoundingClientRect.y + fieldBoundingClientRect.height + : blockCoords.y + this.height; + + return new Coordinate( + this.RTL ? blockCoords.x - 5 : blockCoords.x + 5, + y + 5, + ); + } + /** * Show the context menu for this block. * * @param e Mouse event. * @internal */ - showContextMenu(e: PointerEvent) { - const menuOptions = this.generateContextMenu(); + showContextMenu(e: Event) { + const menuOptions = this.generateContextMenu(e); + + const location = this.calculateContextMenuLocation(e); if (menuOptions && menuOptions.length) { - ContextMenu.show(e, menuOptions, this.RTL, this.workspace); + ContextMenu.show(e, menuOptions, this.RTL, this.workspace, location); ContextMenu.setCurrentBlock(this); } } @@ -699,6 +722,24 @@ export class BlockSvg } } + /** + * Add a CSS class to the SVG group of this block. + * + * @param className + */ + addClass(className: string) { + dom.addClass(this.svgGroup, className); + } + + /** + * Remove a CSS class from the SVG group of this block. + * + * @param className + */ + removeClass(className: string) { + dom.removeClass(this.svgGroup, className); + } + /** * Recursively adds or removes the dragging class to this node and its * children. @@ -711,10 +752,10 @@ export class BlockSvg if (adding) { this.translation = ''; common.draggingConnections.push(...this.getConnections_(true)); - dom.addClass(this.svgGroup, 'blocklyDragging'); + this.addClass('blocklyDragging'); } else { common.draggingConnections.length = 0; - dom.removeClass(this.svgGroup, 'blocklyDragging'); + this.removeClass('blocklyDragging'); } // Recurse through all blocks attached under this one. for (let i = 0; i < this.childBlocks_.length; i++) { @@ -739,6 +780,13 @@ export class BlockSvg */ override setEditable(editable: boolean) { super.setEditable(editable); + + if (editable) { + dom.removeClass(this.svgGroup, 'blocklyNotEditable'); + } else { + dom.addClass(this.svgGroup, 'blocklyNotEditable'); + } + const icons = this.getIcons(); for (let i = 0; i < icons.length; i++) { icons[i].updateEditable(); @@ -806,25 +854,6 @@ export class BlockSvg blockAnimations.disposeUiEffect(this); } - // Selecting a shadow block highlights an ancestor block, but that highlight - // should be removed if the shadow block will be deleted. So, before - // deleting blocks and severing the connections between them, check whether - // doing so would delete a selected block and make sure that any associated - // parent is updated. - const selection = common.getSelected(); - if (selection instanceof Block) { - let selectionAncestor: Block | null = selection; - while (selectionAncestor !== null) { - if (selectionAncestor === this) { - // The block to be deleted contains the selected block, so remove any - // selection highlight associated with the selected block before - // deleting them. - selection.unselect(); - } - selectionAncestor = selectionAncestor.getParent(); - } - } - super.dispose(!!healStack); dom.removeNode(this.svgGroup); } @@ -837,8 +866,7 @@ export class BlockSvg this.disposing = true; super.disposeInternal(); - if (common.getSelected() === this) { - this.unselect(); + if (getFocusManager().getFocusedNode() === this) { this.workspace.cancelCurrentGesture(); } @@ -896,17 +924,15 @@ export class BlockSvg * @internal */ applyColour() { - this.pathObject.applyColour(this); + this.pathObject.applyColour?.(this); const icons = this.getIcons(); for (let i = 0; i < icons.length; i++) { icons[i].applyColour(); } - for (let x = 0, input; (input = this.inputList[x]); x++) { - for (let y = 0, field; (field = input.fieldRow[y]); y++) { - field.applyColour(); - } + for (const field of this.getFields()) { + field.applyColour(); } } @@ -1052,30 +1078,6 @@ export class BlockSvg return removed; } - /** - * @deprecated v11 - Set whether the block is manually enabled or disabled. - * The user can toggle whether a block is disabled from a context menu - * option. A block may still be disabled for other reasons even if the user - * attempts to manually enable it, such as when the block is in an invalid - * location. This method is deprecated and setDisabledReason should be used - * instead. - * - * @param enabled True if enabled. - */ - override setEnabled(enabled: boolean) { - deprecation.warn( - 'setEnabled', - 'v11', - 'v12', - 'the setDisabledReason method of BlockSvg', - ); - const wasEnabled = this.isEnabled(); - super.setEnabled(enabled); - if (this.isEnabled() !== wasEnabled && !this.getInheritedDisabled()) { - this.updateDisabled(); - } - } - /** * Add or remove a reason why the block might be disabled. If a block has * any reasons to be disabled, then the block itself will be considered @@ -1098,6 +1100,20 @@ export class BlockSvg } } + /** + * Add blocklyNotDeletable class when block is not deletable + * Or remove class when block is deletable + */ + override setDeletable(deletable: boolean) { + super.setDeletable(deletable); + + if (deletable) { + dom.removeClass(this.svgGroup, 'blocklyNotDeletable'); + } else { + dom.addClass(this.svgGroup, 'blocklyNotDeletable'); + } + } + /** * Set whether the block is highlighted or not. Block highlighting is * often used to visually mark blocks currently being executed. @@ -1162,7 +1178,7 @@ export class BlockSvg .getConstants() .getBlockStyleForColour(this.colour_); - this.pathObject.setStyle(styleObj.style); + this.pathObject.setStyle?.(styleObj.style); this.style = styleObj.style; this.styleName_ = styleObj.name; @@ -1180,16 +1196,22 @@ export class BlockSvg .getRenderer() .getConstants() .getBlockStyle(blockStyleName); - this.styleName_ = blockStyleName; + + if (this.styleName_) { + dom.removeClass(this.svgGroup, this.styleName_); + } if (blockStyle) { this.hat = blockStyle.hat; - this.pathObject.setStyle(blockStyle); + this.pathObject.setStyle?.(blockStyle); // Set colour to match Block. this.colour_ = blockStyle.colourPrimary; this.style = blockStyle; this.applyColour(); + + dom.addClass(this.svgGroup, blockStyleName); + this.styleName_ = blockStyleName; } else { throw Error('Invalid style name: ' + blockStyleName); } @@ -1216,6 +1238,7 @@ export class BlockSvg * adjusting its parents. */ bringToFront(blockOnly = false) { + const previouslyFocused = getFocusManager().getFocusedNode(); /* eslint-disable-next-line @typescript-eslint/no-this-alias */ let block: this | null = this; if (block.isDeadOrDying()) { @@ -1232,6 +1255,13 @@ export class BlockSvg if (blockOnly) break; block = block.getParent(); } while (block); + if (previouslyFocused) { + // Bringing a block to the front of the stack doesn't fundamentally change + // the logical structure of the page, but it does change element ordering + // which can take automatically take away focus from a node. Ensure focus + // is restored to avoid a discontinuity. + getFocusManager().focusNode(previouslyFocused); + } } /** @@ -1594,7 +1624,6 @@ export class BlockSvg this.tightenChildrenEfficiently(); dom.stopTextWidthCache(); - this.updateMarkers_(); } /** @@ -1614,44 +1643,6 @@ export class BlockSvg if (this.nextConnection) this.nextConnection.tightenEfficiently(); } - /** Redraw any attached marker or cursor svgs if needed. */ - protected updateMarkers_() { - if (this.workspace.keyboardAccessibilityMode && this.pathObject.cursorSvg) { - this.workspace.getCursor()!.draw(); - } - if (this.workspace.keyboardAccessibilityMode && this.pathObject.markerSvg) { - // TODO(#4592): Update all markers on the block. - this.workspace.getMarker(MarkerManager.LOCAL_MARKER)!.draw(); - } - for (const input of this.inputList) { - for (const field of input.fieldRow) { - field.updateMarkers_(); - } - } - } - - /** - * Add the cursor SVG to this block's SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the block SVG - * group. - * @internal - */ - setCursorSvg(cursorSvg: SVGElement) { - this.pathObject.setCursorSvg(cursorSvg); - } - - /** - * Add the marker SVG to this block's SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the block SVG - * group. - * @internal - */ - setMarkerSvg(markerSvg: SVGElement) { - this.pathObject.setMarkerSvg(markerSvg); - } - /** * Returns a bounding box describing the dimensions of this block * and any blocks stacked below it. @@ -1704,6 +1695,16 @@ export class BlockSvg ); } + /** + * Returns the drag strategy currently in use by this block. + * + * @internal + * @returns This block's drag strategy. + */ + getDragStrategy(): IDragStrategy { + return this.dragStrategy; + } + /** Sets the drag strategy for this block. */ setDragStrategy(dragStrategy: IDragStrategy) { this.dragStrategy = dragStrategy; @@ -1759,4 +1760,41 @@ export class BlockSvg traverseJson(json as unknown as {[key: string]: unknown}); return [json]; } + + override jsonInit(json: AnyDuringMigration): void { + super.jsonInit(json); + + if (json['classes']) { + this.addClass( + Array.isArray(json['classes']) + ? json['classes'].join(' ') + : json['classes'], + ); + } + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.pathObject.svgPath; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.select(); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unselect(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } } diff --git a/core/blockly.ts b/core/blockly.ts index 01490dbb694..14383a947a3 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -17,6 +17,7 @@ import './events/events_var_create.js'; import {Block} from './block.js'; import * as blockAnimations from './block_animations.js'; +import {BlockFlyoutInflater} from './block_flyout_inflater.js'; import {BlockSvg} from './block_svg.js'; import {BlocklyOptions} from './blockly_options.js'; import {Blocks} from './blocks.js'; @@ -24,6 +25,7 @@ import * as browserEvents from './browser_events.js'; import * as bubbles from './bubbles.js'; import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js'; import * as bumpObjects from './bump_objects.js'; +import {ButtonFlyoutInflater} from './button_flyout_inflater.js'; import * as clipboard from './clipboard.js'; import * as comments from './comments.js'; import * as common from './common.js'; @@ -62,6 +64,7 @@ import { FieldDropdownConfig, FieldDropdownFromJsonConfig, FieldDropdownValidator, + ImageProperties, MenuGenerator, MenuGeneratorFunction, MenuOption, @@ -99,20 +102,28 @@ import { import {Flyout} from './flyout_base.js'; import {FlyoutButton} from './flyout_button.js'; import {HorizontalFlyout} from './flyout_horizontal.js'; +import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; +import {FlyoutSeparator} from './flyout_separator.js'; import {VerticalFlyout} from './flyout_vertical.js'; +import { + FocusManager, + ReturnEphemeralFocus, + getFocusManager, +} from './focus_manager.js'; import {CodeGenerator} from './generator.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; import * as icons from './icons.js'; import {inject} from './inject.js'; import * as inputs from './inputs.js'; +import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {LabelFlyoutInflater} from './label_flyout_inflater.js'; +import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js'; +import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; + import {Input} from './inputs/input.js'; -import {InsertionMarkerManager} from './insertion_marker_manager.js'; import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js'; -import {IASTNodeLocation} from './interfaces/i_ast_node_location.js'; -import {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js'; -import {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IBubble} from './interfaces/i_bubble.js'; @@ -132,6 +143,8 @@ import { } from './interfaces/i_draggable.js'; import {IDragger} from './interfaces/i_dragger.js'; import {IFlyout} from './interfaces/i_flyout.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {IHasBubble, hasBubble} from './interfaces/i_has_bubble.js'; import {IIcon, isIcon} from './interfaces/i_icon.js'; import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; @@ -155,12 +168,11 @@ import { IVariableBackedParameterModel, isVariableBackedParameterModel, } from './interfaces/i_variable_backed_parameter_model.js'; +import {IVariableMap} from './interfaces/i_variable_map.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import * as internalConstants from './internal_constants.js'; -import {ASTNode} from './keyboard_nav/ast_node.js'; -import {BasicCursor} from './keyboard_nav/basic_cursor.js'; -import {Cursor} from './keyboard_nav/cursor.js'; +import {LineCursor} from './keyboard_nav/line_cursor.js'; import {Marker} from './keyboard_nav/marker.js'; -import {TabNavigateCursor} from './keyboard_nav/tab_navigate_cursor.js'; import type {LayerManager} from './layer_manager.js'; import * as layers from './layers.js'; import {MarkerManager} from './marker_manager.js'; @@ -417,10 +429,20 @@ Names.prototype.populateProcedures = function ( }; // clang-format on +export * from './flyout_navigator.js'; +export * from './interfaces/i_navigation_policy.js'; +export * from './keyboard_nav/block_navigation_policy.js'; +export * from './keyboard_nav/connection_navigation_policy.js'; +export * from './keyboard_nav/field_navigation_policy.js'; +export * from './keyboard_nav/flyout_button_navigation_policy.js'; +export * from './keyboard_nav/flyout_navigation_policy.js'; +export * from './keyboard_nav/flyout_separator_navigation_policy.js'; +export * from './keyboard_nav/workspace_navigation_policy.js'; +export * from './navigator.js'; +export * from './toast.js'; + // Re-export submodules that no longer declareLegacyNamespace. export { - ASTNode, - BasicCursor, Block, BlockSvg, BlocklyOptions, @@ -435,11 +457,11 @@ export { ContextMenuItems, ContextMenuRegistry, Css, - Cursor, DeleteArea, DragTarget, Events, Extensions, + LineCursor, Procedures, ShortcutItems, Themes, @@ -471,6 +493,8 @@ export { }; export const DropDownDiv = dropDownDiv; export { + BlockFlyoutInflater, + ButtonFlyoutInflater, CodeGenerator, Field, FieldCheckbox, @@ -504,14 +528,15 @@ export { FieldVariableValidator, Flyout, FlyoutButton, + FlyoutItem, FlyoutMetricsManager, + FlyoutSeparator, + FocusManager, + FocusableTreeTraverser, CodeGenerator as Generator, Gesture, Grid, HorizontalFlyout, - IASTNodeLocation, - IASTNodeLocationSvg, - IASTNodeLocationWithBlock, IAutoHideable, IBoundedElement, IBubble, @@ -529,6 +554,9 @@ export { IDraggable, IDragger, IFlyout, + IFlyoutInflater, + IFocusableNode, + IFocusableTree, IHasBubble, IIcon, IKeyboardAccessible, @@ -546,9 +574,13 @@ export { IToolbox, IToolboxItem, IVariableBackedParameterModel, + IVariableMap, + IVariableModel, + IVariableState, + ImageProperties, Input, - InsertionMarkerManager, InsertionMarkerPreviewer, + LabelFlyoutInflater, LayerManager, Marker, MarkerManager, @@ -562,10 +594,11 @@ export { Names, Options, RenderedConnection, + ReturnEphemeralFocus, Scrollbar, ScrollbarPair, + SeparatorFlyoutInflater, ShortcutRegistry, - TabNavigateCursor, Theme, ThemeManager, Toolbox, @@ -583,6 +616,7 @@ export { WorkspaceSvg, ZoomControls, config, + getFocusManager, hasBubble, icons, inject, diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index bac94dbc8a0..64060fe7888 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -4,11 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {ISelectable} from '../blockly.js'; import * as browserEvents from '../browser_events.js'; import * as common from '../common.js'; import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js'; +import {getFocusManager} from '../focus_manager.js'; import {IBubble} from '../interfaces/i_bubble.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import {ISelectable} from '../interfaces/i_selectable.js'; import {ContainerRegion} from '../metrics_manager.js'; import {Scrollbar} from '../scrollbar.js'; import {Coordinate} from '../utils/coordinate.js'; @@ -86,17 +88,24 @@ export abstract class Bubble implements IBubble, ISelectable { private dragStrategy = new BubbleDragStrategy(this, this.workspace); + private focusableElement: SVGElement | HTMLElement; + /** * @param workspace The workspace this bubble belongs to. * @param anchor The anchor location of the thing this bubble is attached to. * The tail of the bubble will point to this location. * @param ownerRect An optional rect we don't want the bubble to overlap with * when automatically positioning. + * @param overriddenFocusableElement An optional replacement to the focusable + * element that's represented by this bubble (as a focusable node). This + * element will have its ID and tabindex overwritten. If not provided, the + * focusable element of this node will default to the bubble's SVG root. */ constructor( public readonly workspace: WorkspaceSvg, protected anchor: Coordinate, protected ownerRect?: Rect, + overriddenFocusableElement?: SVGElement | HTMLElement, ) { this.id = idGenerator.getNextUniqueId(); this.svgRoot = dom.createSvgElement( @@ -106,11 +115,7 @@ export abstract class Bubble implements IBubble, ISelectable { ); const embossGroup = dom.createSvgElement( Svg.G, - { - 'filter': `url(#${ - this.workspace.getRenderer().getConstants().embossFilterId - })`, - }, + {'class': 'blocklyEmboss'}, this.svgRoot, ); this.tail = dom.createSvgElement( @@ -131,6 +136,10 @@ export abstract class Bubble implements IBubble, ISelectable { ); this.contentContainer = dom.createSvgElement(Svg.G, {}, this.svgRoot); + this.focusableElement = overriddenFocusableElement ?? this.svgRoot; + this.focusableElement.setAttribute('id', this.id); + this.focusableElement.setAttribute('tabindex', '-1'); + browserEvents.conditionalBind( this.background, 'pointerdown', @@ -212,11 +221,13 @@ export abstract class Bubble implements IBubble, ISelectable { this.background.setAttribute('fill', colour); } - /** Brings the bubble to the front and passes the pointer event off to the gesture system. */ + /** + * Passes the pointer event off to the gesture system and ensures the bubble + * is focused. + */ private onMouseDown(e: PointerEvent) { this.workspace.getGesture(e)?.handleBubbleStart(e, this); - this.bringToFront(); - common.setSelected(this); + getFocusManager().focusNode(this); } /** Positions the bubble relative to its anchor. Does not render its tail. */ @@ -651,9 +662,37 @@ export abstract class Bubble implements IBubble, ISelectable { select(): void { // Bubbles don't have any visual for being selected. + common.fireSelectedEvent(this); } unselect(): void { // Bubbles don't have any visual for being selected. + common.fireSelectedEvent(null); + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.focusableElement; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.select(); + this.bringToFront(); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unselect(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; } } diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts index f4ad96c8c00..f6ea609361b 100644 --- a/core/bubbles/mini_workspace_bubble.ts +++ b/core/bubbles/mini_workspace_bubble.ts @@ -80,6 +80,7 @@ export class MiniWorkspaceBubble extends Bubble { flyout?.show(options.languageTree); } + dom.addClass(this.svgRoot, 'blocklyMiniWorkspaceBubble'); this.miniWorkspace.addChangeListener(this.onWorkspaceChange.bind(this)); this.miniWorkspace .getFlyout() diff --git a/core/bubbles/text_bubble.ts b/core/bubbles/text_bubble.ts index 6db81cd99bc..99299fa50e8 100644 --- a/core/bubbles/text_bubble.ts +++ b/core/bubbles/text_bubble.ts @@ -27,6 +27,7 @@ export class TextBubble extends Bubble { super(workspace, anchor, ownerRect); this.paragraph = this.stringToSvg(text, this.contentContainer); this.updateBubbleSize(); + dom.addClass(this.svgRoot, 'blocklyTextBubble'); } /** @returns the current text of this text bubble. */ diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 5b5278b91ff..6281ad7584e 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -48,6 +48,9 @@ export class TextInputBubble extends Bubble { /** Functions listening for changes to the size of this bubble. */ private sizeChangeListeners: (() => void)[] = []; + /** Functions listening for changes to the location of this bubble. */ + private locationChangeListeners: (() => void)[] = []; + /** The text of this bubble. */ private text = ''; @@ -77,11 +80,10 @@ export class TextInputBubble extends Bubble { protected anchor: Coordinate, protected ownerRect?: Rect, ) { - super(workspace, anchor, ownerRect); + super(workspace, anchor, ownerRect, TextInputBubble.createTextArea()); dom.addClass(this.svgRoot, 'blocklyTextInputBubble'); - ({inputRoot: this.inputRoot, textArea: this.textArea} = this.createEditor( - this.contentContainer, - )); + this.textArea = this.getFocusableElement() as HTMLTextAreaElement; + this.inputRoot = this.createEditor(this.contentContainer, this.textArea); this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace); this.setSize(this.DEFAULT_SIZE, true); } @@ -123,11 +125,26 @@ export class TextInputBubble extends Bubble { this.sizeChangeListeners.push(listener); } - /** Creates the editor UI for this bubble. */ - private createEditor(container: SVGGElement): { - inputRoot: SVGForeignObjectElement; - textArea: HTMLTextAreaElement; - } { + /** Adds a change listener to be notified when this bubble's location changes. */ + addLocationChangeListener(listener: () => void) { + this.locationChangeListeners.push(listener); + } + + /** Creates and returns the editable text area for this bubble's editor. */ + private static createTextArea(): HTMLTextAreaElement { + const textArea = document.createElementNS( + dom.HTML_NS, + 'textarea', + ) as HTMLTextAreaElement; + textArea.className = 'blocklyTextarea blocklyText'; + return textArea; + } + + /** Creates and returns the UI container element for this bubble's editor. */ + private createEditor( + container: SVGGElement, + textArea: HTMLTextAreaElement, + ): SVGForeignObjectElement { const inputRoot = dom.createSvgElement( Svg.FOREIGNOBJECT, { @@ -141,22 +158,13 @@ export class TextInputBubble extends Bubble { body.setAttribute('xmlns', dom.HTML_NS); body.className = 'blocklyMinimalBody'; - const textArea = document.createElementNS( - dom.HTML_NS, - 'textarea', - ) as HTMLTextAreaElement; - textArea.className = 'blocklyTextarea blocklyText'; textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); - body.appendChild(textArea); inputRoot.appendChild(body); this.bindTextAreaEvents(textArea); - setTimeout(() => { - textArea.focus(); - }, 0); - return {inputRoot, textArea}; + return inputRoot; } /** Binds events to the text area element. */ @@ -166,13 +174,6 @@ export class TextInputBubble extends Bubble { e.stopPropagation(); }); - browserEvents.conditionalBind( - textArea, - 'focus', - this, - this.onStartEdit, - true, - ); browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); } @@ -230,10 +231,25 @@ export class TextInputBubble extends Bubble { /** @returns the size of this bubble. */ getSize(): Size { - // Overriden to be public. + // Overridden to be public. return super.getSize(); } + override moveDuringDrag(newLoc: Coordinate) { + super.moveDuringDrag(newLoc); + this.onLocationChange(); + } + + override setPositionRelativeToAnchor(left: number, top: number) { + super.setPositionRelativeToAnchor(left, top); + this.onLocationChange(); + } + + protected override positionByRect(rect = new Rect(0, 0, 0, 0)) { + super.positionByRect(rect); + this.onLocationChange(); + } + /** Handles mouse down events on the resize target. */ private onResizePointerDown(e: PointerEvent) { this.bringToFront(); @@ -291,17 +307,6 @@ export class TextInputBubble extends Bubble { this.onSizeChange(); } - /** - * Handles starting an edit of the text area. Brings the bubble to the front. - */ - private onStartEdit() { - if (this.bringToFront()) { - // Since the act of moving this node within the DOM causes a loss of - // focus, we need to reapply the focus. - this.textArea.focus(); - } - } - /** Handles a text change event for the text area. Calls event listeners. */ private onTextChange() { this.text = this.textArea.value; @@ -316,6 +321,13 @@ export class TextInputBubble extends Bubble { listener(); } } + + /** Handles a location change event for the text area. Calls event listeners. */ + private onLocationChange() { + for (const listener of this.locationChangeListeners) { + listener(); + } + } } Css.register(` diff --git a/core/button_flyout_inflater.ts b/core/button_flyout_inflater.ts new file mode 100644 index 00000000000..4f083f015f7 --- /dev/null +++ b/core/button_flyout_inflater.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutButton} from './flyout_button.js'; +import {FlyoutItem} from './flyout_item.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import {ButtonOrLabelInfo} from './utils/toolbox.js'; + +const BUTTON_TYPE = 'button'; + +/** + * Class responsible for creating buttons for flyouts. + */ +export class ButtonFlyoutInflater implements IFlyoutInflater { + /** + * Inflates a flyout button from the given state and adds it to the flyout. + * + * @param state A JSON representation of a flyout button. + * @param flyout The flyout to create the button on. + * @returns A newly created FlyoutButton. + */ + load(state: object, flyout: IFlyout): FlyoutItem { + const button = new FlyoutButton( + flyout.getWorkspace(), + flyout.targetWorkspace!, + state as ButtonOrLabelInfo, + false, + ); + button.show(); + + return new FlyoutItem(button, BUTTON_TYPE); + } + + /** + * Returns the amount of space that should follow this button. + * + * @param state A JSON representation of a flyout button. + * @param defaultGap The default spacing for flyout items. + * @returns The amount of space that should follow this button. + */ + gapForItem(state: object, defaultGap: number): number { + return defaultGap; + } + + /** + * Disposes of the given button. + * + * @param item The flyout button to dispose of. + */ + disposeItem(item: FlyoutItem): void { + const element = item.getElement(); + if (element instanceof FlyoutButton) { + element.dispose(); + } + } + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return BUTTON_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + BUTTON_TYPE, + ButtonFlyoutInflater, +); diff --git a/core/clipboard.ts b/core/clipboard.ts index ba6f44e6f4c..5fa654d630c 100644 --- a/core/clipboard.ts +++ b/core/clipboard.ts @@ -6,7 +6,7 @@ // Former goog.module ID: Blockly.clipboard -import {BlockPaster} from './clipboard/block_paster.js'; +import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; import * as registry from './clipboard/registry.js'; import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; import * as globalRegistry from './registry.js'; @@ -112,4 +112,4 @@ export const TEST_ONLY = { copyInternal, }; -export {BlockPaster, registry}; +export {BlockCopyData, BlockPaster, registry}; diff --git a/core/clipboard/block_paster.ts b/core/clipboard/block_paster.ts index 08ff220ee91..750cedca124 100644 --- a/core/clipboard/block_paster.ts +++ b/core/clipboard/block_paster.ts @@ -5,12 +5,14 @@ */ import {BlockSvg} from '../block_svg.js'; -import * as common from '../common.js'; +import {IFocusableNode} from '../blockly.js'; import {config} from '../config.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; import {ICopyData} from '../interfaces/i_copyable.js'; import {IPaster} from '../interfaces/i_paster.js'; +import * as renderManagement from '../render_management.js'; import {State, append} from '../serialization/blocks.js'; import {Coordinate} from '../utils/coordinate.js'; import {WorkspaceSvg} from '../workspace_svg.js'; @@ -55,7 +57,13 @@ export class BlockPaster implements IPaster { if (eventUtils.isEnabled() && !block.isShadow()) { eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(block)); } - common.setSelected(block); + + // Sometimes there's a delay before the block is fully created and ready for + // focusing, so wait slightly before focusing the newly pasted block. + const nodeToFocus: IFocusableNode = block; + renderManagement + .finishQueuedRenders() + .then(() => getFocusManager().focusNode(nodeToFocus)); return block; } } diff --git a/core/clipboard/workspace_comment_paster.ts b/core/clipboard/workspace_comment_paster.ts index fdfbf0a8419..00c56681dd3 100644 --- a/core/clipboard/workspace_comment_paster.ts +++ b/core/clipboard/workspace_comment_paster.ts @@ -5,9 +5,9 @@ */ import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import * as common from '../common.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; import {ICopyData} from '../interfaces/i_copyable.js'; import {IPaster} from '../interfaces/i_paster.js'; import * as commentSerialiation from '../serialization/workspace_comments.js'; @@ -49,7 +49,7 @@ export class WorkspaceCommentPaster if (eventUtils.isEnabled()) { eventUtils.fire(new (eventUtils.get(EventType.COMMENT_CREATE))(comment)); } - common.setSelected(comment); + getFocusManager().focusNode(comment); return comment; } } diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 99c14aaa8f2..26623d40f74 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -53,7 +53,7 @@ export class CommentView implements IRenderedElement { private textArea: HTMLTextAreaElement; /** The current size of the comment in workspace units. */ - private size: Size = new Size(120, 100); + private size: Size; /** Whether the comment is collapsed or not. */ private collapsed: boolean = false; @@ -95,15 +95,18 @@ export class CommentView implements IRenderedElement { private resizePointerMoveListener: browserEvents.Data | null = null; /** Whether this comment view is currently being disposed or not. */ - private disposing = false; + protected disposing = false; /** Whether this comment view has been disposed or not. */ - private disposed = false; + protected disposed = false; /** Size of this comment when the resize drag was initiated. */ private preResizeSize?: Size; - constructor(private readonly workspace: WorkspaceSvg) { + /** The default size of newly created comments. */ + static defaultCommentSize = new Size(120, 100); + + constructor(readonly workspace: WorkspaceSvg) { this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); @@ -129,6 +132,7 @@ export class CommentView implements IRenderedElement { workspace.getLayerManager()?.append(this, layers.BLOCK); // Set size to the default size. + this.size = CommentView.defaultCommentSize; this.setSizeWithoutFiringEvents(this.size); // Set default transform (including inverted scale for RTL). @@ -685,6 +689,11 @@ export class CommentView implements IRenderedElement { this.onTextChange(); } + /** Sets the placeholder text displayed for an empty comment. */ + setPlaceholderText(text: string) { + this.textArea.placeholder = text; + } + /** Registers a callback that listens for text changes. */ addTextChangeListener(listener: (oldText: string, newText: string) => void) { this.textChangeListeners.push(listener); diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index f4885df46f7..76eeb64f16a 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -13,11 +13,13 @@ import * as common from '../common.js'; import * as contextMenu from '../contextmenu.js'; import {ContextMenuRegistry} from '../contextmenu_registry.js'; import {CommentDragStrategy} from '../dragging/comment_drag_strategy.js'; +import {getFocusManager} from '../focus_manager.js'; import {IBoundedElement} from '../interfaces/i_bounded_element.js'; import {IContextMenu} from '../interfaces/i_contextmenu.js'; import {ICopyable} from '../interfaces/i_copyable.js'; import {IDeletable} from '../interfaces/i_deletable.js'; import {IDraggable} from '../interfaces/i_draggable.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import {ISelectable} from '../interfaces/i_selectable.js'; import * as layers from '../layers.js'; @@ -26,6 +28,7 @@ import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; import {Size} from '../utils/size.js'; +import * as svgMath from '../utils/svg_math.js'; import {WorkspaceSvg} from '../workspace_svg.js'; import {CommentView} from './comment_view.js'; import {WorkspaceComment} from './workspace_comment.js'; @@ -59,6 +62,8 @@ export class RenderedWorkspaceComment this.view.setSize(this.getSize()); this.view.setEditable(this.isEditable()); this.view.getSvgRoot().setAttribute('data-id', this.id); + this.view.getSvgRoot().setAttribute('id', this.id); + this.view.getSvgRoot().setAttribute('tabindex', '-1'); this.addModelUpdateBindings(); @@ -105,6 +110,11 @@ export class RenderedWorkspaceComment this.view.setText(text); } + /** Sets the placeholder text displayed if the comment is empty. */ + setPlaceholderText(text: string): void { + this.view.setPlaceholderText(text); + } + /** Sets the size of the comment. */ override setSize(size: Size) { // setSize will trigger the change listener that updates @@ -214,9 +224,8 @@ export class RenderedWorkspaceComment e.stopPropagation(); } else { gesture.handleCommentStart(e, this); - this.workspace.getLayerManager()?.append(this, layers.BLOCK); } - common.setSelected(this); + getFocusManager().focusNode(this); } } @@ -257,11 +266,13 @@ export class RenderedWorkspaceComment /** Visually highlights the comment. */ select(): void { dom.addClass(this.getSvgRoot(), 'blocklySelected'); + common.fireSelectedEvent(this); } /** Visually unhighlights the comment. */ unselect(): void { dom.removeClass(this.getSvgRoot(), 'blocklySelected'); + common.fireSelectedEvent(null); } /** @@ -278,12 +289,31 @@ export class RenderedWorkspaceComment } /** Show a context menu for this comment. */ - showContextMenu(e: PointerEvent): void { + showContextMenu(e: Event): void { const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( - ContextMenuRegistry.ScopeType.COMMENT, - {comment: this}, + {comment: this, focusedNode: this}, + e, + ); + + let location: Coordinate; + if (e instanceof PointerEvent) { + location = new Coordinate(e.clientX, e.clientY); + } else { + // Show the menu based on the location of the comment + const xy = svgMath.wsToScreenCoordinates( + this.workspace, + this.getRelativeToSurfaceXY(), + ); + location = xy.translate(10, 10); + } + + contextMenu.show( + e, + menuOptions, + this.workspace.RTL, + this.workspace, + location, ); - contextMenu.show(e, menuOptions, this.workspace.RTL, this.workspace); } /** Snap this comment to the nearest grid point. */ @@ -297,4 +327,31 @@ export class RenderedWorkspaceComment this.moveTo(alignedXY, ['snap']); } } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.getSvgRoot(); + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.select(); + // Ensure that the comment is always at the top when focused. + this.workspace.getLayerManager()?.append(this, layers.BLOCK); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unselect(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } } diff --git a/core/comments/workspace_comment.ts b/core/comments/workspace_comment.ts index 2d59c715edd..190efd64dd1 100644 --- a/core/comments/workspace_comment.ts +++ b/core/comments/workspace_comment.ts @@ -12,6 +12,7 @@ import {Coordinate} from '../utils/coordinate.js'; import * as idGenerator from '../utils/idgenerator.js'; import {Size} from '../utils/size.js'; import {Workspace} from '../workspace.js'; +import {CommentView} from './comment_view.js'; export class WorkspaceComment { /** The unique identifier for this comment. */ @@ -21,7 +22,7 @@ export class WorkspaceComment { private text = ''; /** The size of the comment in workspace units. */ - private size = new Size(120, 100); + private size: Size; /** Whether the comment is collapsed or not. */ private collapsed = false; @@ -56,6 +57,7 @@ export class WorkspaceComment { id?: string, ) { this.id = id && !workspace.getCommentById(id) ? id : idGenerator.genUid(); + this.size = CommentView.defaultCommentSize; workspace.addTopComment(this); @@ -142,7 +144,7 @@ export class WorkspaceComment { * workspace is read-only. */ isEditable(): boolean { - return this.isOwnEditable() && !this.workspace.options.readOnly; + return this.isOwnEditable() && !this.workspace.isReadOnly(); } /** @@ -163,7 +165,7 @@ export class WorkspaceComment { * workspace is read-only. */ isMovable() { - return this.isOwnMovable() && !this.workspace.options.readOnly; + return this.isOwnMovable() && !this.workspace.isReadOnly(); } /** @@ -187,7 +189,7 @@ export class WorkspaceComment { return ( this.isOwnDeletable() && !this.isDeadOrDying() && - !this.workspace.options.readOnly + !this.workspace.isReadOnly() ); } diff --git a/core/common.ts b/core/common.ts index bc31bf17eea..1f7ba7e88df 100644 --- a/core/common.ts +++ b/core/common.ts @@ -7,11 +7,12 @@ // Former goog.module ID: Blockly.common import type {Block} from './block.js'; -import {ISelectable} from './blockly.js'; import {BlockDefinition, Blocks} from './blocks.js'; import type {Connection} from './connection.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; +import {ISelectable, isSelectable} from './interfaces/i_selectable.js'; import type {Workspace} from './workspace.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -86,38 +87,45 @@ export function setMainWorkspace(workspace: Workspace) { } /** - * Currently selected copyable object. + * Returns the current selection. */ -let selected: ISelectable | null = null; +export function getSelected(): ISelectable | null { + const focused = getFocusManager().getFocusedNode(); + if (focused && isSelectable(focused)) return focused; + return null; +} /** - * Returns the currently selected copyable object. + * Sets the current selection. + * + * To clear the current selection, select another ISelectable or focus a + * non-selectable (like the workspace root node). + * + * @param newSelection The new selection to make. + * @internal */ -export function getSelected(): ISelectable | null { - return selected; +export function setSelected(newSelection: ISelectable) { + getFocusManager().focusNode(newSelection); } /** - * Sets the currently selected block. This function does not visually mark the - * block as selected or fire the required events. If you wish to - * programmatically select a block, use `BlockSvg#select`. + * Fires a selection change event based on the new selection. + * + * This is only expected to be called by ISelectable implementations and should + * always be called before updating the current selection state. It does not + * change focus or selection state. * - * @param newSelection The newly selected block. + * @param newSelection The new selection. * @internal */ -export function setSelected(newSelection: ISelectable | null) { - if (selected === newSelection) return; - +export function fireSelectedEvent(newSelection: ISelectable | null) { + const selected = getSelected(); const event = new (eventUtils.get(EventType.SELECTED))( selected?.id ?? null, newSelection?.id ?? null, newSelection?.workspace.id ?? selected?.workspace.id ?? '', ); eventUtils.fire(event); - - selected?.unselect(); - selected = newSelection; - selected?.select(); } /** diff --git a/core/connection.ts b/core/connection.ts index 9cc2c28a923..fbd094dba69 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -17,15 +17,15 @@ import type {BlockMove} from './events/events_block_move.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import type {Input} from './inputs/input.js'; -import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import * as blocks from './serialization/blocks.js'; +import {idGenerator} from './utils.js'; import * as Xml from './xml.js'; /** * Class for a connection between blocks. */ -export class Connection implements IASTNodeLocationWithBlock { +export class Connection { /** Constants for checking whether two connections are compatible. */ static CAN_CONNECT = 0; static REASON_SELF_CONNECTION = 1; @@ -55,6 +55,9 @@ export class Connection implements IASTNodeLocationWithBlock { /** DOM representation of a shadow block, or null if none. */ private shadowDom: Element | null = null; + /** The unique ID of this connection. */ + id: string; + /** * Horizontal location of this connection. * @@ -80,6 +83,7 @@ export class Connection implements IASTNodeLocationWithBlock { public type: number, ) { this.sourceBlock_ = source; + this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`; } /** @@ -485,7 +489,7 @@ export class Connection implements IASTNodeLocationWithBlock { * * Headless configurations (the default) do not have neighboring connection, * and always return an empty list (the default). - * {@link RenderedConnection#neighbours} overrides this behavior with a list + * {@link (RenderedConnection:class).neighbours} overrides this behavior with a list * computed from the rendered positioning. * * @param _maxLimit The maximum radius to another connection. diff --git a/core/contextmenu.ts b/core/contextmenu.ts index b49dcba51c0..f3ebbd1c681 100644 --- a/core/contextmenu.ts +++ b/core/contextmenu.ts @@ -9,7 +9,6 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; -import * as common from './common.js'; import {config} from './config.js'; import type { ContextMenuOption, @@ -17,10 +16,13 @@ import type { } from './contextmenu_registry.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; import {Menu} from './menu.js'; +import {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; import * as serializationBlocks from './serialization/blocks.js'; import * as aria from './utils/aria.js'; +import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; import * as svgMath from './utils/svg_math.js'; @@ -37,6 +39,8 @@ const dummyOwner = {}; /** * Gets the block the context menu is currently attached to. + * It is not recommended that you use this function; instead, + * use the scope object passed to the context menu callback. * * @returns The block the context menu is attached to. */ @@ -61,26 +65,38 @@ let menu_: Menu | null = null; /** * Construct the menu based on the list of options and show the menu. * - * @param e Mouse event. + * @param menuOpenEvent Event that caused the menu to open. * @param options Array of menu options. * @param rtl True if RTL, false if LTR. * @param workspace The workspace associated with the context menu, if any. + * @param location The screen coordinates at which to show the menu. */ export function show( - e: PointerEvent, + menuOpenEvent: Event, options: (ContextMenuOption | LegacyContextMenuOption)[], rtl: boolean, workspace?: WorkspaceSvg, + location?: Coordinate, ) { WidgetDiv.show(dummyOwner, rtl, dispose, workspace); if (!options.length) { hide(); return; } - const menu = populate_(options, rtl, e); + + if (!location) { + if (menuOpenEvent instanceof PointerEvent) { + location = new Coordinate(menuOpenEvent.clientX, menuOpenEvent.clientY); + } else { + // We got a keyboard event that didn't tell us where to open the menu, so just guess + console.warn('Context menu opened with keyboard but no location given'); + location = new Coordinate(0, 0); + } + } + const menu = populate_(options, rtl, menuOpenEvent, location); menu_ = menu; - position_(menu, e, rtl); + position_(menu, rtl, location); // 1ms delay is required for focusing on context menus because some other // mouse event is still waiting in the queue and clears focus. setTimeout(function () { @@ -94,13 +110,15 @@ export function show( * * @param options Array of menu options. * @param rtl True if RTL, false if LTR. - * @param e The event that triggered the context menu to open. + * @param menuOpenEvent The event that triggered the context menu to open. + * @param location The screen coordinates at which to show the menu. * @returns The menu that will be shown on right click. */ function populate_( options: (ContextMenuOption | LegacyContextMenuOption)[], rtl: boolean, - e: PointerEvent, + menuOpenEvent: Event, + location: Coordinate, ): Menu { /* Here's what one option object looks like: {text: 'Make It So', @@ -111,13 +129,18 @@ function populate_( menu.setRole(aria.Role.MENU); for (let i = 0; i < options.length; i++) { const option = options[i]; + if (option.separator) { + menu.addChild(new MenuSeparator()); + continue; + } + const menuItem = new MenuItem(option.text); menuItem.setRightToLeft(rtl); menuItem.setRole(aria.Role.MENUITEM); menu.addChild(menuItem); menuItem.setEnabled(option.enabled); if (option.enabled) { - const actionHandler = function () { + const actionHandler = function (p1: MenuItem, menuSelectEvent: Event) { hide(); requestAnimationFrame(() => { setTimeout(() => { @@ -125,7 +148,12 @@ function populate_( // will not be expecting a scope parameter, so there should be // no problems. Just assume it is a ContextMenuOption and we'll // pass undefined if it's not. - option.callback((option as ContextMenuOption).scope, e); + option.callback( + (option as ContextMenuOption).scope, + menuOpenEvent, + menuSelectEvent, + location, + ); }, 0); }); }; @@ -139,21 +167,19 @@ function populate_( * Add the menu to the page and position it correctly. * * @param menu The menu to add and position. - * @param e Mouse event for the right click that is making the context - * menu appear. * @param rtl True if RTL, false if LTR. + * @param location The location at which to anchor the menu. */ -function position_(menu: Menu, e: Event, rtl: boolean) { +function position_(menu: Menu, rtl: boolean, location: Coordinate) { // Record windowSize and scrollOffset before adding menu. const viewportBBox = svgMath.getViewportBBox(); - const mouseEvent = e as MouseEvent; // This one is just a point, but we'll pretend that it's a rect so we can use // some helper functions. const anchorBBox = new Rect( - mouseEvent.clientY + viewportBBox.top, - mouseEvent.clientY + viewportBBox.top, - mouseEvent.clientX + viewportBBox.left, - mouseEvent.clientX + viewportBBox.left, + location.y + viewportBBox.top, + location.y + viewportBBox.top, + location.x + viewportBBox.left, + location.x + viewportBBox.left, ); createWidget_(menu); @@ -263,7 +289,7 @@ export function callbackFactory( if (eventUtils.isEnabled() && !newBlock.isShadow()) { eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock)); } - common.setSelected(newBlock); + getFocusManager().focusNode(newBlock); return newBlock; }; } diff --git a/core/contextmenu_items.ts b/core/contextmenu_items.ts index 58429fb1381..774bfdde29b 100644 --- a/core/contextmenu_items.ts +++ b/core/contextmenu_items.ts @@ -9,7 +9,6 @@ import type {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; -import * as common from './common.js'; import {MANUALLY_DISABLED} from './constants.js'; import { ContextMenuRegistry, @@ -19,6 +18,7 @@ import { import * as dialog from './dialog.js'; import * as Events from './events/events.js'; import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; import {CommentIcon} from './icons/comment_icon.js'; import {Msg} from './msg.js'; import {StatementInput} from './renderers/zelos/zelos.js'; @@ -614,19 +614,24 @@ export function registerCommentCreate() { preconditionFn: (scope: Scope) => { return scope.workspace?.isMutator ? 'hidden' : 'enabled'; }, - callback: (scope: Scope, e: PointerEvent) => { + callback: ( + scope: Scope, + menuOpenEvent: Event, + menuSelectEvent: Event, + location: Coordinate, + ) => { const workspace = scope.workspace; if (!workspace) return; eventUtils.setGroup(true); const comment = new RenderedWorkspaceComment(workspace); - comment.setText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']); + comment.setPlaceholderText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']); comment.moveTo( pixelsToWorkspaceCoords( - new Coordinate(e.clientX, e.clientY), + new Coordinate(location.x, location.y), workspace, ), ); - common.setSelected(comment); + getFocusManager().focusNode(comment); eventUtils.setGroup(false); }, scopeType: ContextMenuRegistry.ScopeType.WORKSPACE, diff --git a/core/contextmenu_registry.ts b/core/contextmenu_registry.ts index fb0d899d141..61fac7a719f 100644 --- a/core/contextmenu_registry.ts +++ b/core/contextmenu_registry.ts @@ -13,6 +13,8 @@ import type {BlockSvg} from './block_svg.js'; import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {Coordinate} from './utils/coordinate.js'; import type {WorkspaceSvg} from './workspace_svg.js'; /** @@ -70,39 +72,60 @@ export class ContextMenuRegistry { } /** - * Gets the valid context menu options for the given scope type (e.g. block or - * workspace) and scope. Blocks are only shown if the preconditionFn shows + * Gets the valid context menu options for the given scope. + * Options are only included if the preconditionFn shows * they should not be hidden. * - * @param scopeType Type of scope where menu should be shown (e.g. on a block - * or on a workspace) * @param scope Current scope of context menu (i.e., the exact workspace or - * block being clicked on) + * block being clicked on). + * @param menuOpenEvent Event that caused the menu to open. * @returns the list of ContextMenuOptions */ getContextMenuOptions( - scopeType: ScopeType, scope: Scope, + menuOpenEvent: Event, ): ContextMenuOption[] { const menuOptions: ContextMenuOption[] = []; for (const item of this.registeredItems.values()) { - if (scopeType === item.scopeType) { - const precondition = item.preconditionFn(scope); - if (precondition !== 'hidden') { - const displayText = - typeof item.displayText === 'function' - ? item.displayText(scope) - : item.displayText; - const menuOption: ContextMenuOption = { - text: displayText, - enabled: precondition === 'enabled', - callback: item.callback, - scope, - weight: item.weight, - }; - menuOptions.push(menuOption); - } + if (item.scopeType) { + // If the scopeType is present, check to make sure + // that the option is compatible with the current scope + if (item.scopeType === ScopeType.BLOCK && !scope.block) continue; + if (item.scopeType === ScopeType.COMMENT && !scope.comment) continue; + if (item.scopeType === ScopeType.WORKSPACE && !scope.workspace) + continue; } + let menuOption: + | ContextMenuRegistry.CoreContextMenuOption + | ContextMenuRegistry.SeparatorContextMenuOption + | ContextMenuRegistry.ActionContextMenuOption; + menuOption = { + scope, + weight: item.weight, + }; + + if (item.separator) { + menuOption = { + ...menuOption, + separator: true, + }; + } else { + const precondition = item.preconditionFn(scope, menuOpenEvent); + if (precondition === 'hidden') continue; + + const displayText = + typeof item.displayText === 'function' + ? item.displayText(scope) + : item.displayText; + menuOption = { + ...menuOption, + text: displayText, + callback: item.callback, + enabled: precondition === 'enabled', + }; + } + + menuOptions.push(menuOption); } menuOptions.sort(function (a, b) { return a.weight - b.weight; @@ -124,50 +147,110 @@ export namespace ContextMenuRegistry { } /** - * The actual workspace/block where the menu is being rendered. This is passed - * to callback and displayText functions that depend on this information. + * The actual workspace/block/focused object where the menu is being + * rendered. This is passed to callback and displayText functions + * that depend on this information. */ export interface Scope { block?: BlockSvg; workspace?: WorkspaceSvg; comment?: RenderedWorkspaceComment; + focusedNode?: IFocusableNode; } /** - * A menu item as entered in the registry. + * Fields common to all context menu registry items. */ - export interface RegistryItem { + interface CoreRegistryItem { + scopeType?: ScopeType; + weight: number; + id: string; + } + + /** + * A representation of a normal, clickable menu item in the registry. + */ + interface ActionRegistryItem extends CoreRegistryItem { /** * @param scope Object that provides a reference to the thing that had its * context menu opened. - * @param e The original event that triggered the context menu to open. Not - * the event that triggered the click on the option. + * @param menuOpenEvent The original event that triggered the context menu to open. + * @param menuSelectEvent The event that triggered the option being selected. + * @param location The location in screen coordinates where the menu was opened. */ - callback: (scope: Scope, e: PointerEvent) => void; - scopeType: ScopeType; + callback: ( + scope: Scope, + menuOpenEvent: Event, + menuSelectEvent: Event, + location: Coordinate, + ) => void; displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement; - preconditionFn: (p1: Scope) => string; + preconditionFn: (p1: Scope, menuOpenEvent: Event) => string; + separator?: never; + } + + /** + * A representation of a menu separator item in the registry. + */ + interface SeparatorRegistryItem extends CoreRegistryItem { + separator: true; + callback?: never; + displayText?: never; + preconditionFn?: never; + } + + /** + * A menu item as entered in the registry. + */ + export type RegistryItem = ActionRegistryItem | SeparatorRegistryItem; + + /** + * Fields common to all context menu items as used by contextmenu.ts. + */ + export interface CoreContextMenuOption { + scope: Scope; weight: number; - id: string; } /** - * A menu item as presented to contextmenu.js. + * A representation of a normal, clickable menu item in contextmenu.ts. */ - export interface ContextMenuOption { + export interface ActionContextMenuOption extends CoreContextMenuOption { text: string | HTMLElement; enabled: boolean; /** * @param scope Object that provides a reference to the thing that had its * context menu opened. - * @param e The original event that triggered the context menu to open. Not - * the event that triggered the click on the option. + * @param menuOpenEvent The original event that triggered the context menu to open. + * @param menuSelectEvent The event that triggered the option being selected. + * @param location The location in screen coordinates where the menu was opened. */ - callback: (scope: Scope, e: PointerEvent) => void; - scope: Scope; - weight: number; + callback: ( + scope: Scope, + menuOpenEvent: Event, + menuSelectEvent: Event, + location: Coordinate, + ) => void; + separator?: never; + } + + /** + * A representation of a menu separator item in contextmenu.ts. + */ + export interface SeparatorContextMenuOption extends CoreContextMenuOption { + separator: true; + text?: never; + enabled?: never; + callback?: never; } + /** + * A menu item as presented to contextmenu.ts. + */ + export type ContextMenuOption = + | ActionContextMenuOption + | SeparatorContextMenuOption; + /** * A subset of ContextMenuOption corresponding to what was publicly * documented. ContextMenuOption should be preferred for new code. @@ -176,6 +259,7 @@ export namespace ContextMenuRegistry { text: string; enabled: boolean; callback: (p1: Scope) => void; + separator?: never; } /** diff --git a/core/css.ts b/core/css.ts index d0e06704162..6b5e19a585b 100644 --- a/core/css.ts +++ b/core/css.ts @@ -5,7 +5,6 @@ */ // Former goog.module ID: Blockly.Css - /** Has CSS already been injected? */ let injected = false; @@ -83,17 +82,15 @@ let content = ` -webkit-user-select: none; } -.blocklyNonSelectable { - user-select: none; - -ms-user-select: none; - -webkit-user-select: none; -} - .blocklyBlockCanvas.blocklyCanvasTransitioning, .blocklyBubbleCanvas.blocklyCanvasTransitioning { transition: transform .5s; } +.blocklyEmboss { + filter: var(--blocklyEmbossFilter); +} + .blocklyTooltipDiv { background-color: #ffffc7; border: 1px solid #ddc; @@ -121,15 +118,12 @@ let content = ` box-shadow: 0 0 3px 1px rgba(0,0,0,.3); } -.blocklyDropDownDiv.blocklyFocused { +.blocklyDropDownDiv:focus { box-shadow: 0 0 6px 1px rgba(0,0,0,.3); } .blocklyDropDownContent { max-height: 300px; /* @todo: spec for maximum height. */ - overflow: auto; - overflow-x: hidden; - position: relative; } .blocklyDropDownArrow { @@ -141,47 +135,14 @@ let content = ` z-index: -1; background-color: inherit; border-color: inherit; -} - -.blocklyDropDownButton { - display: inline-block; - float: left; - padding: 0; - margin: 4px; - border-radius: 4px; - outline: none; - border: 1px solid; - transition: box-shadow .1s; - cursor: pointer; -} - -.blocklyArrowTop { border-top: 1px solid; border-left: 1px solid; border-top-left-radius: 4px; border-color: inherit; } -.blocklyArrowBottom { - border-bottom: 1px solid; - border-right: 1px solid; - border-bottom-right-radius: 4px; - border-color: inherit; -} - -.blocklyResizeSE { - cursor: se-resize; - fill: #aaa; -} - -.blocklyResizeSW { - cursor: sw-resize; - fill: #aaa; -} - -.blocklyResizeLine { - stroke: #515A5A; - stroke-width: 1; +.blocklyHighlighted>.blocklyPath { + filter: var(--blocklyEmbossFilter); } .blocklyHighlightedConnectionPath { @@ -234,7 +195,8 @@ let content = ` display: none; } -.blocklyDisabled>.blocklyPath { +.blocklyDisabledPattern>.blocklyPath { + fill: var(--blocklyDisabledPattern); fill-opacity: .5; stroke-opacity: .5; } @@ -251,7 +213,7 @@ let content = ` stroke: none; } -.blocklyNonEditableText>text { +.blocklyNonEditableField>text { pointer-events: none; } @@ -264,12 +226,15 @@ let content = ` cursor: default; } -.blocklyHidden { - display: none; -} - -.blocklyFieldDropdown:not(.blocklyHidden) { - display: block; +/* + Don't allow users to select text. It gets annoying when trying to + drag a block and selected text moves instead. +*/ +.blocklySvg text { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + cursor: inherit; } .blocklyIconGroup { @@ -419,6 +384,9 @@ input[type=number] { } .blocklyWidgetDiv .blocklyMenu { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; background: #fff; border: 1px solid transparent; box-shadow: 0 0 3px 1px rgba(0,0,0,.3); @@ -433,16 +401,21 @@ input[type=number] { z-index: 20000; /* Arbitrary, but some apps depend on it... */ } -.blocklyWidgetDiv .blocklyMenu.blocklyFocused { +.blocklyWidgetDiv .blocklyMenu:focus { box-shadow: 0 0 6px 1px rgba(0,0,0,.3); } .blocklyDropDownDiv .blocklyMenu { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; background: inherit; /* Compatibility with gapi, reset from goog-menu */ border: inherit; /* Compatibility with gapi, reset from goog-menu */ font: normal 13px "Helvetica Neue", Helvetica, sans-serif; outline: none; - position: relative; /* Compatibility with gapi, reset from goog-menu */ + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; z-index: 20000; /* Arbitrary, but some apps depend on it... */ } @@ -489,6 +462,14 @@ input[type=number] { margin-right: -24px; } +.blocklyMenuSeparator { + background-color: #ccc; + height: 1px; + border: 0; + margin-left: 4px; + margin-right: 4px; +} + .blocklyBlockDragSurface, .blocklyAnimationLayer { position: absolute; top: 0; @@ -499,4 +480,31 @@ input[type=number] { z-index: 80; pointer-events: none; } + +.blocklyField { + cursor: default; +} + +.blocklyInputField { + cursor: text; +} + +.blocklyDragging .blocklyField, +.blocklyDragging .blocklyIconGroup { + cursor: grabbing; +} + +.blocklyActiveFocus:is( + .blocklyFlyout, + .blocklyWorkspace, + .blocklyField, + .blocklyPath, + .blocklyHighlightedConnectionPath, + .blocklyComment, + .blocklyBubble, + .blocklyIconGroup, + .blocklyTextarea +) { + outline-width: 0px; +} `; diff --git a/core/dialog.ts b/core/dialog.ts index 7e21129855c..96631e9cbc7 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -6,31 +6,43 @@ // Former goog.module ID: Blockly.dialog -let alertImplementation = function ( - message: string, - opt_callback?: () => void, -) { +import type {ToastOptions} from './toast.js'; +import {Toast} from './toast.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +const defaultAlert = function (message: string, opt_callback?: () => void) { window.alert(message); if (opt_callback) { opt_callback(); } }; -let confirmImplementation = function ( +let alertImplementation = defaultAlert; + +const defaultConfirm = function ( message: string, callback: (result: boolean) => void, ) { callback(window.confirm(message)); }; -let promptImplementation = function ( +let confirmImplementation = defaultConfirm; + +const defaultPrompt = function ( message: string, defaultValue: string, callback: (result: string | null) => void, ) { + // NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native + // window prompt since it prevents focus from changing while open. callback(window.prompt(message, defaultValue)); }; +let promptImplementation = defaultPrompt; + +const defaultToast = Toast.show.bind(Toast); +let toastImplementation = defaultToast; + /** * Wrapper to window.alert() that app developers may override via setAlert to * provide alternatives to the modal browser window. @@ -45,10 +57,16 @@ export function alert(message: string, opt_callback?: () => void) { /** * Sets the function to be run when Blockly.dialog.alert() is called. * - * @param alertFunction The function to be run. + * @param alertFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.alert */ -export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) { +export function setAlert( + alertFunction: ( + message: string, + callback?: () => void, + ) => void = defaultAlert, +) { alertImplementation = alertFunction; } @@ -59,25 +77,22 @@ export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) { * @param message The message to display to the user. * @param callback The callback for handling user response. */ -export function confirm(message: string, callback: (p1: boolean) => void) { - TEST_ONLY.confirmInternal(message, callback); -} - -/** - * Private version of confirm for stubbing in tests. - */ -function confirmInternal(message: string, callback: (p1: boolean) => void) { +export function confirm(message: string, callback: (result: boolean) => void) { confirmImplementation(message, callback); } /** * Sets the function to be run when Blockly.dialog.confirm() is called. * - * @param confirmFunction The function to be run. + * @param confirmFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.confirm */ export function setConfirm( - confirmFunction: (p1: string, p2: (p1: boolean) => void) => void, + confirmFunction: ( + message: string, + callback: (result: boolean) => void, + ) => void = defaultConfirm, ) { confirmImplementation = confirmFunction; } @@ -95,7 +110,7 @@ export function setConfirm( export function prompt( message: string, defaultValue: string, - callback: (p1: string | null) => void, + callback: (result: string | null) => void, ) { promptImplementation(message, defaultValue, callback); } @@ -103,19 +118,50 @@ export function prompt( /** * Sets the function to be run when Blockly.dialog.prompt() is called. * - * @param promptFunction The function to be run. + * **Important**: When overridding this, be aware that non-native prompt + * experiences may require managing ephemeral focus in FocusManager. This isn't + * needed for the native window prompt because it prevents focus from being + * changed while open. + * + * @param promptFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.prompt */ export function setPrompt( promptFunction: ( - p1: string, - p2: string, - p3: (p1: string | null) => void, - ) => void, + message: string, + defaultValue: string, + callback: (result: string | null) => void, + ) => void = defaultPrompt, ) { promptImplementation = promptFunction; } -export const TEST_ONLY = { - confirmInternal, -}; +/** + * Displays a temporary notification atop the workspace. Blockly provides a + * default toast implementation, but developers may provide their own via + * setToast. For simple appearance customization, CSS should be sufficient. + * + * @param workspace The workspace to display the toast notification atop. + * @param options Configuration options for the notification, including its + * message and duration. + */ +export function toast(workspace: WorkspaceSvg, options: ToastOptions) { + toastImplementation(workspace, options); +} + +/** + * Sets the function to be run when Blockly.dialog.toast() is called. + * + * @param toastFunction The function to be run, or undefined to restore the + * default implementation. + * @see Blockly.dialog.toast + */ +export function setToast( + toastFunction: ( + workspace: WorkspaceSvg, + options: ToastOptions, + ) => void = defaultToast, +) { + toastImplementation = toastFunction; +} diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index c9a1ea0abf7..76020f90b5b 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -62,8 +62,8 @@ export class BlockDragStrategy implements IDragStrategy { */ private dragOffset = new Coordinate(0, 0); - /** Was there already an event group in progress when the drag started? */ - private inGroup: boolean = false; + /** Used to persist an event group when snapping is done async. */ + private originalEventGroup = ''; constructor(private block: BlockSvg) { this.workspace = block.workspace; @@ -78,7 +78,7 @@ export class BlockDragStrategy implements IDragStrategy { return ( this.block.isOwnMovable() && !this.block.isDeadOrDying() && - !this.workspace.options.readOnly && + !this.workspace.isReadOnly() && // We never drag blocks in the flyout, only create new blocks that are // dragged. !this.block.isInFlyout @@ -96,10 +96,6 @@ export class BlockDragStrategy implements IDragStrategy { } this.dragging = true; - this.inGroup = !!eventUtils.getGroup(); - if (!this.inGroup) { - eventUtils.setGroup(true); - } this.fireDragStartEvent(); this.startLoc = this.block.getRelativeToSurfaceXY(); @@ -117,7 +113,7 @@ export class BlockDragStrategy implements IDragStrategy { this.workspace.setResizesEnabled(false); blockAnimation.disconnectUiStop(); - const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey); + const healStack = this.shouldHealStack(e); if (this.shouldDisconnect(healStack)) { this.disconnectBlock(healStack); @@ -126,6 +122,17 @@ export class BlockDragStrategy implements IDragStrategy { this.workspace.getLayerManager()?.moveToDragLayer(this.block); } + /** + * Get whether the drag should act on a single block or a block stack. + * + * @param e The instigating pointer event, if any. + * @returns True if just the initial block should be dragged out, false + * if all following blocks should also be dragged. + */ + protected shouldHealStack(e: PointerEvent | undefined) { + return !!e && (e.altKey || e.ctrlKey || e.metaKey); + } + /** Starts a drag on a shadow, recording the drag offset. */ private startDraggingShadow(e?: PointerEvent) { const parent = this.block.getParent(); @@ -231,7 +238,7 @@ export class BlockDragStrategy implements IDragStrategy { const currCandidate = this.connectionCandidate; const newCandidate = this.getConnectionCandidate(draggingBlock, delta); if (!newCandidate) { - this.connectionPreviewer!.hidePreview(); + this.connectionPreviewer?.hidePreview(); this.connectionCandidate = null; return; } @@ -247,7 +254,7 @@ export class BlockDragStrategy implements IDragStrategy { local.type === ConnectionType.OUTPUT_VALUE || local.type === ConnectionType.PREVIOUS_STATEMENT; const neighbourIsConnectedToRealBlock = - neighbour.isConnected() && !neighbour.targetBlock()!.isInsertionMarker(); + neighbour.isConnected() && !neighbour.targetBlock()?.isInsertionMarker(); if ( localIsOutputOrPrevious && neighbourIsConnectedToRealBlock && @@ -257,14 +264,14 @@ export class BlockDragStrategy implements IDragStrategy { local.type, ) ) { - this.connectionPreviewer!.previewReplacement( + this.connectionPreviewer?.previewReplacement( local, neighbour, neighbour.targetBlock()!, ); return; } - this.connectionPreviewer!.previewConnection(local, neighbour); + this.connectionPreviewer?.previewConnection(local, neighbour); } /** @@ -319,9 +326,7 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, ): ConnectionCandidate | null { const localConns = this.getLocalConnections(draggingBlock); - let radius = this.connectionCandidate - ? config.connectingSnapRadius - : config.snapRadius; + let radius = this.getSearchRadius(); let candidate = null; for (const conn of localConns) { @@ -339,6 +344,15 @@ export class BlockDragStrategy implements IDragStrategy { return candidate; } + /** + * Get the radius to use when searching for a nearby valid connection. + */ + protected getSearchRadius() { + return this.connectionCandidate + ? config.connectingSnapRadius + : config.snapRadius; + } + /** * Returns all of the connections we might connect to blocks on the workspace. * @@ -363,6 +377,7 @@ export class BlockDragStrategy implements IDragStrategy { this.block.getParent()?.endDrag(e); return; } + this.originalEventGroup = eventUtils.getGroup(); this.fireDragEndEvent(); this.fireMoveEvent(); @@ -370,7 +385,7 @@ export class BlockDragStrategy implements IDragStrategy { dom.stopTextWidthCache(); blockAnimation.disconnectUiStop(); - this.connectionPreviewer!.hidePreview(); + this.connectionPreviewer?.hidePreview(); if (!this.block.isDeadOrDying() && this.dragging) { // These are expensive and don't need to be done if we're deleting, or @@ -388,20 +403,19 @@ export class BlockDragStrategy implements IDragStrategy { } else { this.block.queueRender().then(() => this.disposeStep()); } - - if (!this.inGroup) { - eventUtils.setGroup(false); - } } /** Disposes of any state at the end of the drag. */ private disposeStep() { + const newGroup = eventUtils.getGroup(); + eventUtils.setGroup(this.originalEventGroup); this.block.snapToGrid(); // Must dispose after connections are applied to not break the dynamic // connections plugin. See #7859 - this.connectionPreviewer!.dispose(); + this.connectionPreviewer?.dispose(); this.workspace.setResizesEnabled(true); + eventUtils.setGroup(newGroup); } /** Connects the given candidate connections. */ @@ -431,6 +445,9 @@ export class BlockDragStrategy implements IDragStrategy { return; } + this.connectionPreviewer?.hidePreview(); + this.connectionCandidate = null; + this.startChildConn?.connect(this.block.nextConnection); if (this.startParentConn) { switch (this.startParentConn.type) { @@ -457,9 +474,6 @@ export class BlockDragStrategy implements IDragStrategy { this.startChildConn = null; this.startParentConn = null; - this.connectionPreviewer!.hidePreview(); - this.connectionCandidate = null; - this.block.setDragging(false); this.dragging = false; } diff --git a/core/dragging/bubble_drag_strategy.ts b/core/dragging/bubble_drag_strategy.ts index c2a5c58f4a2..8a5a6783910 100644 --- a/core/dragging/bubble_drag_strategy.ts +++ b/core/dragging/bubble_drag_strategy.ts @@ -5,7 +5,6 @@ */ import {IBubble, WorkspaceSvg} from '../blockly.js'; -import * as eventUtils from '../events/utils.js'; import {IDragStrategy} from '../interfaces/i_draggable.js'; import * as layers from '../layers.js'; import {Coordinate} from '../utils.js'; @@ -13,9 +12,6 @@ import {Coordinate} from '../utils.js'; export class BubbleDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; - /** Was there already an event group in progress when the drag started? */ - private inGroup: boolean = false; - constructor( private bubble: IBubble, private workspace: WorkspaceSvg, @@ -26,10 +22,6 @@ export class BubbleDragStrategy implements IDragStrategy { } startDrag(): void { - this.inGroup = !!eventUtils.getGroup(); - if (!this.inGroup) { - eventUtils.setGroup(true); - } this.startLoc = this.bubble.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); this.workspace.getLayerManager()?.moveToDragLayer(this.bubble); @@ -44,9 +36,6 @@ export class BubbleDragStrategy implements IDragStrategy { endDrag(): void { this.workspace.setResizesEnabled(true); - if (!this.inGroup) { - eventUtils.setGroup(false); - } this.workspace .getLayerManager() diff --git a/core/dragging/comment_drag_strategy.ts b/core/dragging/comment_drag_strategy.ts index dd8b10fc2f9..b7974d8b4ca 100644 --- a/core/dragging/comment_drag_strategy.ts +++ b/core/dragging/comment_drag_strategy.ts @@ -18,9 +18,6 @@ export class CommentDragStrategy implements IDragStrategy { private workspace: WorkspaceSvg; - /** Was there already an event group in progress when the drag started? */ - private inGroup: boolean = false; - constructor(private comment: RenderedWorkspaceComment) { this.workspace = comment.workspace; } @@ -29,15 +26,11 @@ export class CommentDragStrategy implements IDragStrategy { return ( this.comment.isOwnMovable() && !this.comment.isDeadOrDying() && - !this.workspace.options.readOnly + !this.workspace.isReadOnly() ); } startDrag(): void { - this.inGroup = !!eventUtils.getGroup(); - if (!this.inGroup) { - eventUtils.setGroup(true); - } this.fireDragStartEvent(); this.startLoc = this.comment.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); @@ -61,9 +54,6 @@ export class CommentDragStrategy implements IDragStrategy { this.comment.snapToGrid(); this.workspace.setResizesEnabled(true); - if (!this.inGroup) { - eventUtils.setGroup(false); - } } /** Fire a UI event at the start of a comment drag. */ diff --git a/core/dragging/dragger.ts b/core/dragging/dragger.ts index 8a9ac87c6a9..02e9e2bfb79 100644 --- a/core/dragging/dragger.ts +++ b/core/dragging/dragger.ts @@ -8,11 +8,13 @@ import * as blockAnimations from '../block_animations.js'; import {BlockSvg} from '../block_svg.js'; import {ComponentManager} from '../component_manager.js'; import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; import {IDeletable, isDeletable} from '../interfaces/i_deletable.js'; import {IDeleteArea} from '../interfaces/i_delete_area.js'; import {IDragTarget} from '../interfaces/i_drag_target.js'; import {IDraggable} from '../interfaces/i_draggable.js'; import {IDragger} from '../interfaces/i_dragger.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; import {Coordinate} from '../utils/coordinate.js'; import {WorkspaceSvg} from '../workspace_svg.js'; @@ -31,6 +33,9 @@ export class Dragger implements IDragger { /** Handles any drag startup. */ onDragStart(e: PointerEvent) { + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } this.draggable.startDrag(e); } @@ -119,12 +124,18 @@ export class Dragger implements IDragger { this.draggable.endDrag(e); if (wouldDelete && isDeletable(root)) { - // We want to make sure the delete gets grouped with any possible - // move event. - const newGroup = eventUtils.getGroup(); + // We want to make sure the delete gets grouped with any possible move + // event. In core Blockly this shouldn't happen, but due to a change + // in behavior older custom draggables might still clear the group. eventUtils.setGroup(origGroup); root.dispose(); - eventUtils.setGroup(newGroup); + } + eventUtils.setGroup(false); + + if (!wouldDelete && isFocusableNode(this.draggable)) { + // Ensure focusable nodes that have finished dragging (but aren't being + // deleted) end with focus and selection. + getFocusManager().focusNode(this.draggable); } } diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index f9af02ac9f7..894724d4448 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -15,6 +15,7 @@ import type {BlockSvg} from './block_svg.js'; import * as common from './common.js'; import type {Field} from './field.js'; +import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; import * as dom from './utils/dom.js'; import * as math from './utils/math.js'; import {Rect} from './utils/rect.js'; @@ -82,6 +83,9 @@ let owner: Field | null = null; /** Whether the dropdown was positioned to a field or the source block. */ let positionToField: boolean | null = null; +/** Callback to FocusManager to return ephemeral focus when the div closes. */ +let returnEphemeralFocus: ReturnEphemeralFocus | null = null; + /** * Dropdown bounds info object used to encapsulate sizing information about a * bounding element (bounding box and width/height). @@ -133,15 +137,6 @@ export function createDom() { // Transition animation for transform: translate() and opacity. div.style.transition = 'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's'; - - // Handle focusin/out events to add a visual indicator when - // a child is focused or blurred. - div.addEventListener('focusin', function () { - dom.addClass(div, 'blocklyFocused'); - }); - div.addEventListener('focusout', function () { - dom.removeClass(div, 'blocklyFocused'); - }); } /** @@ -166,14 +161,14 @@ export function getOwner(): Field | null { * * @returns Div to populate with content. */ -export function getContentDiv(): Element { +export function getContentDiv(): HTMLDivElement { return content; } /** Clear the content of the drop-down. */ export function clearContent() { - content.textContent = ''; - content.style.width = ''; + div.remove(); + createDom(); } /** @@ -273,6 +268,11 @@ function getScaledBboxOfField(field: Field): Rect { * @param field The field to position the dropdown against. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( @@ -280,6 +280,7 @@ function showPositionedByRect( field: Field, opt_onHide?: () => void, opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, ): boolean { // If we can fit it, render below the block. const primaryX = bBox.left + (bBox.right - bBox.left) / 2; @@ -304,6 +305,7 @@ function showPositionedByRect( primaryY, secondaryX, secondaryY, + manageEphemeralFocus, opt_onHide, ); } @@ -324,6 +326,8 @@ function showPositionedByRect( * @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px. * @param opt_onHide Optional callback for when the drop-down is hidden. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the widget div's lifetime. * @returns True if the menu rendered at the primary origin point. * @internal */ @@ -334,6 +338,7 @@ export function show( primaryY: number, secondaryX: number, secondaryY: number, + manageEphemeralFocus: boolean, opt_onHide?: () => void, ): boolean { owner = newOwner as Field; @@ -344,11 +349,11 @@ export function show( const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg; renderedClassName = mainWorkspace.getRenderer().getClassName(); themeClassName = mainWorkspace.getTheme().getClassName(); - if (renderedClassName) { - dom.addClass(div, renderedClassName); - } - if (themeClassName) { - dom.addClass(div, themeClassName); + dom.addClass(div, renderedClassName); + dom.addClass(div, themeClassName); + + if (manageEphemeralFocus) { + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); } // When we change `translate` multiple times in close succession, @@ -651,16 +656,6 @@ export function hideWithoutAnimation() { clearTimeout(animateOutTimer); } - // Reset style properties in case this gets called directly - // instead of hide() - see discussion on #2551. - div.style.transform = ''; - div.style.left = ''; - div.style.top = ''; - div.style.opacity = '0'; - div.style.display = 'none'; - div.style.backgroundColor = ''; - div.style.borderColor = ''; - if (onHide) { onHide(); onHide = null; @@ -668,15 +663,12 @@ export function hideWithoutAnimation() { clearContent(); owner = null; - if (renderedClassName) { - dom.removeClass(div, renderedClassName); - renderedClassName = ''; - } - if (themeClassName) { - dom.removeClass(div, themeClassName); - themeClassName = ''; - } (common.getMainWorkspace() as WorkspaceSvg).markFocused(); + + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } } /** @@ -703,19 +695,12 @@ function positionInternal( // Update arrow CSS. if (metrics.arrowVisible) { + const x = metrics.arrowX; + const y = metrics.arrowY; + const rotation = metrics.arrowAtTop ? 45 : 225; arrow.style.display = ''; - arrow.style.transform = - 'translate(' + - metrics.arrowX + - 'px,' + - metrics.arrowY + - 'px) rotate(45deg)'; - arrow.setAttribute( - 'class', - metrics.arrowAtTop - ? 'blocklyDropDownArrow blocklyArrowTop' - : 'blocklyDropDownArrow blocklyArrowBottom', - ); + arrow.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`; + arrow.setAttribute('class', 'blocklyDropDownArrow'); } else { arrow.style.display = 'none'; } diff --git a/core/events/events.ts b/core/events/events.ts index 86899565381..dcddf19a925 100644 --- a/core/events/events.ts +++ b/core/events/events.ts @@ -33,19 +33,21 @@ export {CommentDelete} from './events_comment_delete.js'; export {CommentDrag, CommentDragJson} from './events_comment_drag.js'; export {CommentMove, CommentMoveJson} from './events_comment_move.js'; export {CommentResize, CommentResizeJson} from './events_comment_resize.js'; -export {MarkerMove, MarkerMoveJson} from './events_marker_move.js'; export {Selected, SelectedJson} from './events_selected.js'; export {ThemeChange, ThemeChangeJson} from './events_theme_change.js'; export { ToolboxItemSelect, ToolboxItemSelectJson, } from './events_toolbox_item_select.js'; + +// Events. export {TrashcanOpen, TrashcanOpenJson} from './events_trashcan_open.js'; export {UiBase} from './events_ui_base.js'; export {VarBase, VarBaseJson} from './events_var_base.js'; export {VarCreate, VarCreateJson} from './events_var_create.js'; export {VarDelete, VarDeleteJson} from './events_var_delete.js'; export {VarRename, VarRenameJson} from './events_var_rename.js'; +export {VarTypeChange, VarTypeChangeJson} from './events_var_type_change.js'; export {ViewportChange, ViewportChangeJson} from './events_viewport.js'; export {FinishedLoading} from './workspace_events.js'; @@ -74,7 +76,6 @@ export const CREATE = EventType.BLOCK_CREATE; /** @deprecated Use BLOCK_DELETE instead */ export const DELETE = EventType.BLOCK_DELETE; export const FINISHED_LOADING = EventType.FINISHED_LOADING; -export const MARKER_MOVE = EventType.MARKER_MOVE; /** @deprecated Use BLOCK_MOVE instead */ export const MOVE = EventType.BLOCK_MOVE; export const SELECTED = EventType.SELECTED; diff --git a/core/events/events_marker_move.ts b/core/events/events_marker_move.ts deleted file mode 100644 index 58309df5896..00000000000 --- a/core/events/events_marker_move.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Events fired as a result of a marker move. - * - * @class - */ -// Former goog.module ID: Blockly.Events.MarkerMove - -import type {Block} from '../block.js'; -import {ASTNode} from '../keyboard_nav/ast_node.js'; -import * as registry from '../registry.js'; -import type {Workspace} from '../workspace.js'; -import {AbstractEventJson} from './events_abstract.js'; -import {UiBase} from './events_ui_base.js'; -import {EventType} from './type.js'; - -/** - * Notifies listeners that a marker (used for keyboard navigation) has - * moved. - */ -export class MarkerMove extends UiBase { - /** The ID of the block the marker is now on, if any. */ - blockId?: string; - - /** The old node the marker used to be on, if any. */ - oldNode?: ASTNode; - - /** The new node the marker is now on. */ - newNode?: ASTNode; - - /** - * True if this is a cursor event, false otherwise. - * For information about cursors vs markers see {@link - * https://blocklycodelabs.dev/codelabs/keyboard-navigation/index.html?index=..%2F..index#1}. - */ - isCursor?: boolean; - - override type = EventType.MARKER_MOVE; - - /** - * @param opt_block The affected block. Null if current node is of type - * workspace. Undefined for a blank event. - * @param isCursor Whether this is a cursor event. Undefined for a blank - * event. - * @param opt_oldNode The old node the marker used to be on. - * Undefined for a blank event. - * @param opt_newNode The new node the marker is now on. - * Undefined for a blank event. - */ - constructor( - opt_block?: Block | null, - isCursor?: boolean, - opt_oldNode?: ASTNode | null, - opt_newNode?: ASTNode, - ) { - let workspaceId = opt_block ? opt_block.workspace.id : undefined; - if (opt_newNode && opt_newNode.getType() === ASTNode.types.WORKSPACE) { - workspaceId = (opt_newNode.getLocation() as Workspace).id; - } - super(workspaceId); - - this.blockId = opt_block?.id; - this.oldNode = opt_oldNode || undefined; - this.newNode = opt_newNode; - this.isCursor = isCursor; - } - - /** - * Encode the event as JSON. - * - * @returns JSON representation. - */ - override toJson(): MarkerMoveJson { - const json = super.toJson() as MarkerMoveJson; - if (this.isCursor === undefined) { - throw new Error( - 'Whether this is a cursor event or not is undefined. Either pass ' + - 'a value to the constructor, or call fromJson', - ); - } - if (!this.newNode) { - throw new Error( - 'The new node is undefined. Either pass a node to ' + - 'the constructor, or call fromJson', - ); - } - json['isCursor'] = this.isCursor; - json['blockId'] = this.blockId; - json['oldNode'] = this.oldNode; - json['newNode'] = this.newNode; - return json; - } - - /** - * Deserializes the JSON event. - * - * @param event The event to append new properties to. Should be a subclass - * of MarkerMove, but we can't specify that due to the fact that - * parameters to static methods in subclasses must be supertypes of - * parameters to static methods in superclasses. - * @internal - */ - static fromJson( - json: MarkerMoveJson, - workspace: Workspace, - event?: any, - ): MarkerMove { - const newEvent = super.fromJson( - json, - workspace, - event ?? new MarkerMove(), - ) as MarkerMove; - newEvent.isCursor = json['isCursor']; - newEvent.blockId = json['blockId']; - newEvent.oldNode = json['oldNode']; - newEvent.newNode = json['newNode']; - return newEvent; - } -} - -export interface MarkerMoveJson extends AbstractEventJson { - isCursor: boolean; - blockId?: string; - oldNode?: ASTNode; - newNode: ASTNode; -} - -registry.register(registry.Type.EVENT, EventType.MARKER_MOVE, MarkerMove); diff --git a/core/events/events_var_base.ts b/core/events/events_var_base.ts index 8e359de517f..f128f67b410 100644 --- a/core/events/events_var_base.ts +++ b/core/events/events_var_base.ts @@ -11,7 +11,10 @@ */ // Former goog.module ID: Blockly.Events.VarBase -import type {VariableModel} from '../variable_model.js'; +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; import type {Workspace} from '../workspace.js'; import { Abstract as AbstractEvent, @@ -30,13 +33,13 @@ export class VarBase extends AbstractEvent { * @param opt_variable The variable this event corresponds to. Undefined for * a blank event. */ - constructor(opt_variable?: VariableModel) { + constructor(opt_variable?: IVariableModel) { super(); this.isBlank = typeof opt_variable === 'undefined'; if (!opt_variable) return; this.varId = opt_variable.getId(); - this.workspaceId = opt_variable.workspace.id; + this.workspaceId = opt_variable.getWorkspace().id; } /** diff --git a/core/events/events_var_create.ts b/core/events/events_var_create.ts index b3ae548aa0d..c34c7ff57ae 100644 --- a/core/events/events_var_create.ts +++ b/core/events/events_var_create.ts @@ -11,8 +11,12 @@ */ // Former goog.module ID: Blockly.Events.VarCreate +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; import * as registry from '../registry.js'; -import type {VariableModel} from '../variable_model.js'; + import type {Workspace} from '../workspace.js'; import {VarBase, VarBaseJson} from './events_var_base.js'; import {EventType} from './type.js'; @@ -32,14 +36,14 @@ export class VarCreate extends VarBase { /** * @param opt_variable The created variable. Undefined for a blank event. */ - constructor(opt_variable?: VariableModel) { + constructor(opt_variable?: IVariableModel) { super(opt_variable); if (!opt_variable) { return; // Blank event to be populated by fromJson. } - this.varType = opt_variable.type; - this.varName = opt_variable.name; + this.varType = opt_variable.getType(); + this.varName = opt_variable.getName(); } /** @@ -109,10 +113,12 @@ export class VarCreate extends VarBase { 'the constructor, or call fromJson', ); } + const variableMap = workspace.getVariableMap(); if (forward) { - workspace.createVariable(this.varName, this.varType, this.varId); + variableMap.createVariable(this.varName, this.varType, this.varId); } else { - workspace.deleteVariableById(this.varId); + const variable = variableMap.getVariableById(this.varId); + if (variable) variableMap.deleteVariable(variable); } } } diff --git a/core/events/events_var_delete.ts b/core/events/events_var_delete.ts index caaa1f4874a..62317e36c50 100644 --- a/core/events/events_var_delete.ts +++ b/core/events/events_var_delete.ts @@ -6,16 +6,18 @@ // Former goog.module ID: Blockly.Events.VarDelete +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; import * as registry from '../registry.js'; -import type {VariableModel} from '../variable_model.js'; + import type {Workspace} from '../workspace.js'; import {VarBase, VarBaseJson} from './events_var_base.js'; import {EventType} from './type.js'; /** * Notifies listeners that a variable model has been deleted. - * - * @class */ export class VarDelete extends VarBase { override type = EventType.VAR_DELETE; @@ -27,14 +29,14 @@ export class VarDelete extends VarBase { /** * @param opt_variable The deleted variable. Undefined for a blank event. */ - constructor(opt_variable?: VariableModel) { + constructor(opt_variable?: IVariableModel) { super(opt_variable); if (!opt_variable) { return; // Blank event to be populated by fromJson. } - this.varType = opt_variable.type; - this.varName = opt_variable.name; + this.varType = opt_variable.getType(); + this.varName = opt_variable.getName(); } /** @@ -104,10 +106,12 @@ export class VarDelete extends VarBase { 'the constructor, or call fromJson', ); } + const variableMap = workspace.getVariableMap(); if (forward) { - workspace.deleteVariableById(this.varId); + const variable = variableMap.getVariableById(this.varId); + if (variable) variableMap.deleteVariable(variable); } else { - workspace.createVariable(this.varName, this.varType, this.varId); + variableMap.createVariable(this.varName, this.varType, this.varId); } } } diff --git a/core/events/events_var_rename.ts b/core/events/events_var_rename.ts index b461184cab1..a1758738c22 100644 --- a/core/events/events_var_rename.ts +++ b/core/events/events_var_rename.ts @@ -6,16 +6,18 @@ // Former goog.module ID: Blockly.Events.VarRename +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; import * as registry from '../registry.js'; -import type {VariableModel} from '../variable_model.js'; + import type {Workspace} from '../workspace.js'; import {VarBase, VarBaseJson} from './events_var_base.js'; import {EventType} from './type.js'; /** * Notifies listeners that a variable model was renamed. - * - * @class */ export class VarRename extends VarBase { override type = EventType.VAR_RENAME; @@ -30,13 +32,13 @@ export class VarRename extends VarBase { * @param opt_variable The renamed variable. Undefined for a blank event. * @param newName The new name the variable will be changed to. */ - constructor(opt_variable?: VariableModel, newName?: string) { + constructor(opt_variable?: IVariableModel, newName?: string) { super(opt_variable); if (!opt_variable) { return; // Blank event to be populated by fromJson. } - this.oldName = opt_variable.name; + this.oldName = opt_variable.getName(); this.newName = typeof newName === 'undefined' ? '' : newName; } @@ -113,10 +115,12 @@ export class VarRename extends VarBase { 'the constructor, or call fromJson', ); } + const variableMap = workspace.getVariableMap(); + const variable = variableMap.getVariableById(this.varId); if (forward) { - workspace.renameVariableById(this.varId, this.newName); + if (variable) variableMap.renameVariable(variable, this.newName); } else { - workspace.renameVariableById(this.varId, this.oldName); + if (variable) variableMap.renameVariable(variable, this.oldName); } } } diff --git a/core/events/events_var_type_change.ts b/core/events/events_var_type_change.ts new file mode 100644 index 00000000000..c02a7e45435 --- /dev/null +++ b/core/events/events_var_type_change.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Class for a variable type change event. + * + * @class + */ + +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; +import * as registry from '../registry.js'; + +import type {Workspace} from '../workspace.js'; +import {VarBase, VarBaseJson} from './events_var_base.js'; +import {EventType} from './type.js'; + +/** + * Notifies listeners that a variable's type has changed. + */ +export class VarTypeChange extends VarBase { + override type = EventType.VAR_TYPE_CHANGE; + + /** + * @param variable The variable whose type changed. Undefined for a blank event. + * @param oldType The old type of the variable. Undefined for a blank event. + * @param newType The new type of the variable. Undefined for a blank event. + */ + constructor( + variable?: IVariableModel, + public oldType?: string, + public newType?: string, + ) { + super(variable); + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + override toJson(): VarTypeChangeJson { + const json = super.toJson() as VarTypeChangeJson; + if (!this.oldType || !this.newType) { + throw new Error( + "The variable's types are undefined. Either pass them to " + + 'the constructor, or call fromJson', + ); + } + json['oldType'] = this.oldType; + json['newType'] = this.newType; + return json; + } + + /** + * Deserializes the JSON event. + * + * @param event The event to append new properties to. Should be a subclass + * of VarTypeChange, but we can't specify that due to the fact that + * parameters to static methods in subclasses must be supertypes of + * parameters to static methods in superclasses. + * @internal + */ + static fromJson( + json: VarTypeChangeJson, + workspace: Workspace, + event?: any, + ): VarTypeChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new VarTypeChange(), + ) as VarTypeChange; + newEvent.oldType = json['oldType']; + newEvent.newType = json['newType']; + return newEvent; + } + + /** + * Run a variable type change event. + * + * @param forward True if run forward, false if run backward (undo). + */ + override run(forward: boolean) { + const workspace = this.getEventWorkspace_(); + if (!this.varId) { + throw new Error( + 'The var ID is undefined. Either pass a variable to ' + + 'the constructor, or call fromJson', + ); + } + if (!this.oldType || !this.newType) { + throw new Error( + "The variable's types are undefined. Either pass them to " + + 'the constructor, or call fromJson', + ); + } + const variable = workspace.getVariableMap().getVariableById(this.varId); + if (!variable) return; + if (forward) { + workspace.getVariableMap().changeVariableType(variable, this.newType); + } else { + workspace.getVariableMap().changeVariableType(variable, this.oldType); + } + } +} + +export interface VarTypeChangeJson extends VarBaseJson { + oldType: string; + newType: string; +} + +registry.register( + registry.Type.EVENT, + EventType.VAR_TYPE_CHANGE, + VarTypeChange, +); diff --git a/core/events/predicates.ts b/core/events/predicates.ts index 79d8ca284e4..9e8ce3b3a59 100644 --- a/core/events/predicates.ts +++ b/core/events/predicates.ts @@ -29,7 +29,6 @@ import type {CommentDelete} from './events_comment_delete.js'; import type {CommentDrag} from './events_comment_drag.js'; import type {CommentMove} from './events_comment_move.js'; import type {CommentResize} from './events_comment_resize.js'; -import type {MarkerMove} from './events_marker_move.js'; import type {Selected} from './events_selected.js'; import type {ThemeChange} from './events_theme_change.js'; import type {ToolboxItemSelect} from './events_toolbox_item_select.js'; @@ -99,11 +98,6 @@ export function isClick(event: Abstract): event is Click { return event.type === EventType.CLICK; } -/** @returns true iff event.type is EventType.MARKER_MOVE */ -export function isMarkerMove(event: Abstract): event is MarkerMove { - return event.type === EventType.MARKER_MOVE; -} - /** @returns true iff event.type is EventType.BUBBLE_OPEN */ export function isBubbleOpen(event: Abstract): event is BubbleOpen { return event.type === EventType.BUBBLE_OPEN; diff --git a/core/events/type.ts b/core/events/type.ts index db9ad6c96a3..0928b8ff077 100644 --- a/core/events/type.ts +++ b/core/events/type.ts @@ -28,6 +28,8 @@ export enum EventType { VAR_DELETE = 'var_delete', /** Type of event that renames a variable. */ VAR_RENAME = 'var_rename', + /** Type of event that changes the type of a variable. */ + VAR_TYPE_CHANGE = 'var_type_change', /** * Type of generic event that records a UI change. * diff --git a/core/events/utils.ts b/core/events/utils.ts index 4753e7783d9..ac78c694273 100644 --- a/core/events/utils.ts +++ b/core/events/utils.ts @@ -9,7 +9,6 @@ import type {Block} from '../block.js'; import * as common from '../common.js'; import * as registry from '../registry.js'; -import * as deprecation from '../utils/deprecation.js'; import * as idGenerator from '../utils/idgenerator.js'; import type {Workspace} from '../workspace.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -124,7 +123,7 @@ function fireInternal(event: Abstract) { /** Dispatch all queued events. */ function fireNow() { - const queue = filter(FIRE_QUEUE, true); + const queue = filter(FIRE_QUEUE); FIRE_QUEUE.length = 0; for (const event of queue) { if (!event.workspaceId) continue; @@ -227,18 +226,9 @@ function enqueueEvent(event: Abstract) { * cause them to be reordered. * * @param queue Array of events. - * @param forward True if forward (redo), false if backward (undo). - * This parameter is deprecated: true is now the default and - * calling filter with it set to false will in future not be - * supported. * @returns Array of filtered events. */ -export function filter(queue: Abstract[], forward = true): Abstract[] { - if (!forward) { - deprecation.warn('filter(queue, /*forward=*/false)', 'v11.2', 'v12'); - // Undo was merged in reverse order. - queue = queue.slice().reverse(); // Copy before reversing in place. - } +export function filter(queue: Abstract[]): Abstract[] { const mergedQueue: Abstract[] = []; // Merge duplicates. for (const event of queue) { @@ -290,10 +280,6 @@ export function filter(queue: Abstract[], forward = true): Abstract[] { } // Filter out any events that have become null due to merging. queue = mergedQueue.filter((e) => !e.isNull()); - if (!forward) { - // Restore undo order. - queue.reverse(); - } return queue; } diff --git a/core/extensions.ts b/core/extensions.ts index 0957b7f86ca..59d218d17fa 100644 --- a/core/extensions.ts +++ b/core/extensions.ts @@ -437,7 +437,10 @@ function checkDropdownOptionsInTable( } const options = dropdown.getOptions(); - for (const [, key] of options) { + for (const option of options) { + if (option === FieldDropdown.SEPARATOR) continue; + + const [, key] = option; if (lookupTable[key] === undefined) { console.warn( `No tooltip mapping for value ${key} of field ` + diff --git a/core/field.ts b/core/field.ts index 4c4b90cf55a..f7e01527e5d 100644 --- a/core/field.ts +++ b/core/field.ts @@ -23,17 +23,17 @@ import * as dropDownDiv from './dropdowndiv.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import type {Input} from './inputs/input.js'; -import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js'; -import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; import type {IRegistrable} from './interfaces/i_registrable.js'; import {ISerializable} from './interfaces/i_serializable.js'; -import {MarkerManager} from './marker_manager.js'; import type {ConstantProvider} from './renderers/common/constants.js'; import type {KeyboardShortcut} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; import type {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; +import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; import {Rect} from './utils/rect.js'; import {Size} from './utils/size.js'; @@ -42,7 +42,7 @@ import {Svg} from './utils/svg.js'; import * as userAgent from './utils/useragent.js'; import * as utilsXml from './utils/xml.js'; import * as WidgetDiv from './widgetdiv.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; +import {WorkspaceSvg} from './workspace_svg.js'; /** * A function that is called to validate changes to the field's value before @@ -67,12 +67,7 @@ export type FieldValidator = (newValue: T) => T | null | undefined; * @typeParam T - The value stored on the field. */ export abstract class Field - implements - IASTNodeLocationSvg, - IASTNodeLocationWithBlock, - IKeyboardAccessible, - IRegistrable, - ISerializable + implements IKeyboardAccessible, IRegistrable, ISerializable, IFocusableNode { /** * To overwrite the default value which is set in **Field**, directly update @@ -108,19 +103,28 @@ export abstract class Field * field is not yet initialized. Is *not* guaranteed to be accurate. */ private tooltip: Tooltip.TipInfo | null = null; - protected size_: Size; + + /** This field's dimensions. */ + private size: Size = new Size(0, 0); /** - * Holds the cursors svg element when the cursor is attached to the field. - * This is null if there is no cursor on the field. + * Gets the size of this field. Because getSize() and updateSize() have side + * effects, this acts as a shim for subclasses which wish to adjust field + * bounds when setting/getting the size without triggering unwanted rendering + * or other side effects. Note that subclasses must override *both* get and + * set if either is overridden; the implementation may just call directly + * through to super, but it must exist per the JS spec. */ - private cursorSvg: SVGElement | null = null; + protected get size_(): Size { + return this.size; + } /** - * Holds the markers svg element when the marker is attached to the field. - * This is null if there is no marker on the field. + * Sets the size of this field. */ - private markerSvg: SVGElement | null = null; + protected set size_(newValue: Size) { + this.size = newValue; + } /** The rendered field's SVG group element. */ protected fieldGroup_: SVGGElement | null = null; @@ -194,8 +198,8 @@ export abstract class Field */ SERIALIZABLE = false; - /** Mouse cursor style when over the hotspot that initiates the editor. */ - CURSOR = ''; + /** The unique ID of this field. */ + private id_: string | null = null; /** * @param value The initial value of the field. @@ -261,6 +265,7 @@ export abstract class Field throw Error('Field already bound to a block'); } this.sourceBlock_ = block; + this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } /** @@ -304,7 +309,12 @@ export abstract class Field // Field has already been initialized once. return; } - this.fieldGroup_ = dom.createSvgElement(Svg.G, {}); + const id = this.id_; + if (!id) throw new Error('Expected ID to be defined prior to init.'); + this.fieldGroup_ = dom.createSvgElement(Svg.G, { + 'tabindex': '-1', + 'id': id, + }); if (!this.isVisible()) { this.fieldGroup_.style.display = 'none'; } @@ -324,6 +334,9 @@ export abstract class Field protected initView() { this.createBorderRect_(); this.createTextElement_(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyField'); + } } /** @@ -339,8 +352,10 @@ export abstract class Field * intend because the behavior was kind of hacked in. If you are thinking * about overriding this function, post on the forum with your intended * behavior to see if there's another approach. + * + * @internal */ - protected isFullBlockField(): boolean { + isFullBlockField(): boolean { return !this.borderRect_; } @@ -374,7 +389,7 @@ export abstract class Field this.textElement_ = dom.createSvgElement( Svg.TEXT, { - 'class': 'blocklyText', + 'class': 'blocklyText blocklyFieldText', }, this.fieldGroup_, ); @@ -406,7 +421,6 @@ export abstract class Field * called by Blockly.Xml. * * @param fieldElement The element containing info about the field's state. - * @internal */ fromXml(fieldElement: Element) { // Any because gremlins live here. No touchie! @@ -419,7 +433,6 @@ export abstract class Field * @param fieldElement The element to populate with info about the field's * state. * @returns The element containing info about the field's state. - * @internal */ toXml(fieldElement: Element): Element { // Any because gremlins live here. No touchie! @@ -438,7 +451,6 @@ export abstract class Field * {@link https://developers.devsite.google.com/blockly/guides/create-custom-blocks/fields/customizing-fields/creating#full_serialization_and_backing_data | field serialization docs} * for more information. * @returns JSON serializable state. - * @internal */ saveState(_doFullSerialization?: boolean): AnyDuringMigration { const legacyState = this.saveLegacyState(Field); @@ -453,7 +465,6 @@ export abstract class Field * called by the serialization system. * * @param state The state we want to apply to the field. - * @internal */ loadState(state: AnyDuringMigration) { if (this.loadLegacyState(Field, state)) { @@ -516,8 +527,6 @@ export abstract class Field /** * Dispose of all DOM objects and events belonging to this editable field. - * - * @internal */ dispose() { dropDownDiv.hideIfOwner(this); @@ -538,13 +547,11 @@ export abstract class Field return; } if (this.enabled_ && block.isEditable()) { - dom.addClass(group, 'blocklyEditableText'); - dom.removeClass(group, 'blocklyNonEditableText'); - group.style.cursor = this.CURSOR; + dom.addClass(group, 'blocklyEditableField'); + dom.removeClass(group, 'blocklyNonEditableField'); } else { - dom.addClass(group, 'blocklyNonEditableText'); - dom.removeClass(group, 'blocklyEditableText'); - group.style.cursor = ''; + dom.addClass(group, 'blocklyNonEditableField'); + dom.removeClass(group, 'blocklyEditableField'); } } @@ -833,12 +840,7 @@ export abstract class Field let contentWidth = 0; if (this.textElement_) { - contentWidth = dom.getFastTextWidth( - this.textElement_, - constants!.FIELD_TEXT_FONTSIZE, - constants!.FIELD_TEXT_FONTWEIGHT, - constants!.FIELD_TEXT_FONTFAMILY, - ); + contentWidth = dom.getTextWidth(this.textElement_); totalWidth += contentWidth; } if (!this.isFullBlockField()) { @@ -918,17 +920,6 @@ export abstract class Field if (this.isDirty_) { this.render_(); this.isDirty_ = false; - } else if (this.visible_ && this.size_.width === 0) { - // If the field is not visible the width will be 0 as well, one of the - // problems with the old system. - this.render_(); - // Don't issue a warning if the field is actually zero width. - if (this.size_.width !== 0) { - console.warn( - 'Deprecated use of setting size_.width to 0 to rerender a' + - ' field. Set field.isDirty_ to true instead.', - ); - } } return this.size_; } @@ -992,10 +983,6 @@ export abstract class Field */ protected getDisplayText_(): string { let text = this.getText(); - if (!text) { - // Prevent the field from disappearing if empty. - return Field.NBSP; - } if (text.length > this.maxDisplayLength) { // Truncate displayed string and add an ellipsis ('...'). text = text.substring(0, this.maxDisplayLength - 2) + '…'; @@ -1057,8 +1044,6 @@ export abstract class Field * rerender this field and adjust for any sizing changes. * Other fields on the same block will not rerender, because their sizes have * already been recorded. - * - * @internal */ forceRerender() { this.isDirty_ = true; @@ -1317,7 +1302,6 @@ export abstract class Field * Subclasses may override this. * * @returns True if this field has any variable references. - * @internal */ referencesVariables(): boolean { return false; @@ -1326,8 +1310,6 @@ export abstract class Field /** * Refresh the variable name referenced by this field if this field references * variables. - * - * @internal */ refreshVariableName() {} // NOP @@ -1369,15 +1351,6 @@ export abstract class Field return false; } - /** - * Returns whether or not the field is tab navigable. - * - * @returns True if the field is tab navigable. - */ - isTabNavigable(): boolean { - return false; - } - /** * Handles the given keyboard shortcut. * @@ -1388,62 +1361,32 @@ export abstract class Field return false; } - /** - * Add the cursor SVG to this fields SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the field group. - * @internal - */ - setCursorSvg(cursorSvg: SVGElement) { - if (!cursorSvg) { - this.cursorSvg = null; - return; - } - + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { if (!this.fieldGroup_) { - throw new Error(`The field group is ${this.fieldGroup_}.`); - } - this.fieldGroup_.appendChild(cursorSvg); - this.cursorSvg = cursorSvg; - } - - /** - * Add the marker SVG to this fields SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the field group. - * @internal - */ - setMarkerSvg(markerSvg: SVGElement) { - if (!markerSvg) { - this.markerSvg = null; - return; + throw Error('This field currently has no representative DOM element.'); } - - if (!this.fieldGroup_) { - throw new Error(`The field group is ${this.fieldGroup_}.`); - } - this.fieldGroup_.appendChild(markerSvg); - this.markerSvg = markerSvg; + return this.fieldGroup_; } - /** - * Redraw any attached marker or cursor svgs if needed. - * - * @internal - */ - updateMarkers_() { + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { const block = this.getSourceBlock(); if (!block) { throw new UnattachedFieldError(); } - const workspace = block.workspace as WorkspaceSvg; - if (workspace.keyboardAccessibilityMode && this.cursorSvg) { - workspace.getCursor()!.draw(); - } - if (workspace.keyboardAccessibilityMode && this.markerSvg) { - // TODO(#4592): Update all markers on the field. - workspace.getMarker(MarkerManager.LOCAL_MARKER)!.draw(); - } + return block.workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; } /** diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index 5ae3dfda1ae..55ed42cbf4b 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -35,11 +35,6 @@ export class FieldCheckbox extends Field { */ override SERIALIZABLE = true; - /** - * Mouse cursor style when over the hotspot that initiates editability. - */ - override CURSOR = 'default'; - /** * NOTE: The default value is set in `Field`, so maintain that value instead * of overwriting it here or in the constructor. @@ -114,7 +109,7 @@ export class FieldCheckbox extends Field { super.initView(); const textElement = this.getTextElement(); - dom.addClass(textElement, 'blocklyCheckbox'); + dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; } diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index cd8119af824..9c0d7f29269 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -23,27 +23,23 @@ import { } from './field.js'; import * as fieldRegistry from './field_registry.js'; import {Menu} from './menu.js'; +import {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import * as utilsString from './utils/string.js'; -import * as style from './utils/style.js'; import {Svg} from './utils/svg.js'; /** * Class for an editable dropdown field. */ export class FieldDropdown extends Field { - /** Horizontal distance that a checkmark overhangs the dropdown. */ - static CHECKMARK_OVERHANG = 25; - /** - * Maximum height of the dropdown menu, as a percentage of the viewport - * height. + * Magic constant used to represent a separator in a list of dropdown items. */ - static MAX_MENU_HEIGHT_VH = 0.45; + static readonly SEPARATOR = 'separator'; static ARROW_CHAR = '▾'; @@ -70,9 +66,6 @@ export class FieldDropdown extends Field { */ override SERIALIZABLE = true; - /** Mouse cursor style when over the hotspot that initiates the editor. */ - override CURSOR = 'default'; - protected menuGenerator_?: MenuGenerator; /** A cache of the most recently generated options. */ @@ -198,6 +191,11 @@ export class FieldDropdown extends Field { if (this.borderRect_) { dom.addClass(this.borderRect_, 'blocklyDropdownRect'); } + + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyField'); + dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); + } } /** @@ -262,16 +260,18 @@ export class FieldDropdown extends Field { throw new UnattachedFieldError(); } this.dropdownCreate(); + if (!this.menu_) return; + if (e && typeof e.clientX === 'number') { - this.menu_!.openingCoords = new Coordinate(e.clientX, e.clientY); + this.menu_.openingCoords = new Coordinate(e.clientX, e.clientY); } else { - this.menu_!.openingCoords = null; + this.menu_.openingCoords = null; } // Remove any pre-existing elements in the dropdown. dropDownDiv.clearContent(); // Element gets created in render. - const menuElement = this.menu_!.render(dropDownDiv.getContentDiv()); + const menuElement = this.menu_.render(dropDownDiv.getContentDiv()); dom.addClass(menuElement, 'blocklyDropdownMenu'); if (this.getConstants()!.FIELD_DROPDOWN_COLOURED_DIV) { @@ -282,18 +282,15 @@ export class FieldDropdown extends Field { dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); + dropDownDiv.getContentDiv().style.height = `${this.menu_.getSize().height}px`; + // Focusing needs to be handled after the menu is rendered and positioned. // Otherwise it will cause a page scroll to get the misplaced menu in // view. See issue #1329. - this.menu_!.focus(); + this.menu_.focus(); if (this.selectedMenuItem) { - this.menu_!.setHighlighted(this.selectedMenuItem); - style.scrollIntoContainerView( - this.selectedMenuItem.getElement()!, - dropDownDiv.getContentDiv(), - true, - ); + this.menu_.setHighlighted(this.selectedMenuItem); } this.applyColour(); @@ -312,13 +309,19 @@ export class FieldDropdown extends Field { const options = this.getOptions(false); this.selectedMenuItem = null; for (let i = 0; i < options.length; i++) { - const [label, value] = options[i]; + const option = options[i]; + if (option === FieldDropdown.SEPARATOR) { + menu.addChild(new MenuSeparator()); + continue; + } + + const [label, value] = option; const content = (() => { - if (typeof label === 'object') { + if (isImageProperties(label)) { // Convert ImageProperties to an HTMLImageElement. - const image = new Image(label['width'], label['height']); - image.src = label['src']; - image.alt = label['alt'] || ''; + const image = new Image(label.width, label.height); + image.src = label.src; + image.alt = label.alt; return image; } return label; @@ -501,7 +504,7 @@ export class FieldDropdown extends Field { // Show correct element. const option = this.selectedOption && this.selectedOption[0]; - if (option && typeof option === 'object') { + if (isImageProperties(option)) { this.renderSelectedImage(option); } else { this.renderSelectedText(); @@ -548,12 +551,7 @@ export class FieldDropdown extends Field { height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2, ); } else { - arrowWidth = dom.getFastTextWidth( - this.arrow as SVGTSpanElement, - this.getConstants()!.FIELD_TEXT_FONTSIZE, - this.getConstants()!.FIELD_TEXT_FONTWEIGHT, - this.getConstants()!.FIELD_TEXT_FONTFAMILY, - ); + arrowWidth = dom.getTextWidth(this.arrow as SVGTSpanElement); } this.size_.width = imageWidth + arrowWidth + xPadding * 2; this.size_.height = height; @@ -586,12 +584,7 @@ export class FieldDropdown extends Field { hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, this.getConstants()!.FIELD_TEXT_HEIGHT, ); - const textWidth = dom.getFastTextWidth( - this.getTextElement(), - this.getConstants()!.FIELD_TEXT_FONTSIZE, - this.getConstants()!.FIELD_TEXT_FONTWEIGHT, - this.getConstants()!.FIELD_TEXT_FONTFAMILY, - ); + const textWidth = dom.getTextWidth(this.getTextElement()); const xPadding = hasBorder ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING : 0; @@ -640,7 +633,13 @@ export class FieldDropdown extends Field { /** * Use the `getText_` developer hook to override the field's text * representation. Get the selected option text. If the selected option is - * an image we return the image alt text. + * an image we return the image alt text. If the selected option is + * an HTMLElement, return the title, ariaLabel, or innerText of the + * element. + * + * If you use HTMLElement options in Node.js and call this function, + * ensure that you are supplying an implementation of HTMLElement, + * such as through jsdom-global. * * @returns Selected option text. */ @@ -649,10 +648,23 @@ export class FieldDropdown extends Field { return null; } const option = this.selectedOption[0]; - if (typeof option === 'object') { - return option['alt']; + if (isImageProperties(option)) { + return option.alt; + } else if ( + typeof HTMLElement !== 'undefined' && + option instanceof HTMLElement + ) { + return option.title ?? option.ariaLabel ?? option.innerText; + } else if (typeof option === 'string') { + return option; } - return option; + + console.warn( + "Can't get text for existing dropdown option. If " + + "you're using HTMLElement dropdown options in node, ensure you're " + + 'using jsdom-global or similar.', + ); + return null; } /** @@ -688,7 +700,10 @@ export class FieldDropdown extends Field { suffix?: string; } { let hasImages = false; - const trimmedOptions = options.map(([label, value]): MenuOption => { + const trimmedOptions = options.map((option): MenuOption => { + if (option === FieldDropdown.SEPARATOR) return option; + + const [label, value] = option; if (typeof label === 'string') { return [parsing.replaceMessageReferences(label), value]; } @@ -696,10 +711,9 @@ export class FieldDropdown extends Field { hasImages = true; // Copy the image properties so they're not influenced by the original. // NOTE: No need to deep copy since image properties are only 1 level deep. - const imageLabel = - label.alt !== null - ? {...label, alt: parsing.replaceMessageReferences(label.alt)} - : {...label}; + const imageLabel = isImageProperties(label) + ? {...label, alt: parsing.replaceMessageReferences(label.alt)} + : {...label}; return [imageLabel, value]; }); @@ -769,28 +783,31 @@ export class FieldDropdown extends Field { } let foundError = false; for (let i = 0; i < options.length; i++) { - const tuple = options[i]; - if (!Array.isArray(tuple)) { + const option = options[i]; + if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) { foundError = true; console.error( - `Invalid option[${i}]: Each FieldDropdown option must be an array. - Found: ${tuple}`, + `Invalid option[${i}]: Each FieldDropdown option must be an array or + the string literal 'separator'. Found: ${option}`, ); - } else if (typeof tuple[1] !== 'string') { + } else if (typeof option[1] !== 'string') { foundError = true; console.error( `Invalid option[${i}]: Each FieldDropdown option id must be a string. - Found ${tuple[1]} in: ${tuple}`, + Found ${option[1]} in: ${option}`, ); } else if ( - tuple[0] && - typeof tuple[0] !== 'string' && - typeof tuple[0].src !== 'string' + option[0] && + typeof option[0] !== 'string' && + !isImageProperties(option[0]) && + !( + typeof HTMLElement !== 'undefined' && option[0] instanceof HTMLElement + ) ) { foundError = true; console.error( `Invalid option[${i}]: Each FieldDropdown option must have a string - label or image description. Found ${tuple[0]} in: ${tuple}`, + label, image description, or HTML element. Found ${option[0]} in: ${option}`, ); } } @@ -800,6 +817,27 @@ export class FieldDropdown extends Field { } } +/** + * Returns whether or not an object conforms to the ImageProperties interface. + * + * @param obj The object to test. + * @returns True if the object conforms to ImageProperties, otherwise false. + */ +function isImageProperties(obj: any): obj is ImageProperties { + return ( + obj && + typeof obj === 'object' && + 'src' in obj && + typeof obj.src === 'string' && + 'alt' in obj && + typeof obj.alt === 'string' && + 'width' in obj && + typeof obj.width === 'number' && + 'height' in obj && + typeof obj.height === 'number' + ); +} + /** * Definition of a human-readable image dropdown option. */ @@ -811,11 +849,15 @@ export interface ImageProperties { } /** - * An individual option in the dropdown menu. The first element is the human- - * readable value (text or image), and the second element is the language- - * neutral value. + * An individual option in the dropdown menu. Can be either the string literal + * `separator` for a menu separator item, or an array for normal action menu + * items. In the latter case, the first element is the human-readable value + * (text, ImageProperties object, or HTML element), and the second element is + * the language-neutral value. */ -export type MenuOption = [string | ImageProperties, string]; +export type MenuOption = + | [string | ImageProperties | HTMLElement, string] + | 'separator'; /** * A function that generates an array of menu options for FieldDropdown diff --git a/core/field_image.ts b/core/field_image.ts index 6e83e3405c6..6dfe2530e50 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -27,7 +27,6 @@ export class FieldImage extends Field { * of the field. */ private static readonly Y_PADDING = 1; - protected override size_: Size; protected readonly imageHeight: number; /** The function to be called when this field is clicked. */ @@ -151,6 +150,10 @@ export class FieldImage extends Field { this.value_ as string, ); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyImageField'); + } + if (this.clickHandler) { this.imageElement.style.cursor = 'pointer'; } diff --git a/core/field_input.ts b/core/field_input.ts index 2c8a48e6760..c7921d6f015 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -27,6 +27,7 @@ import { FieldValidator, UnattachedFieldError, } from './field.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; @@ -100,8 +101,25 @@ export abstract class FieldInput extends Field< */ override SERIALIZABLE = true; - /** Mouse cursor style when over the hotspot that initiates the editor. */ - override CURSOR = 'text'; + /** + * Sets the size of this field. Although this appears to be a no-op, it must + * exist since the getter is overridden below. + */ + protected override set size_(newValue: Size) { + super.size_ = newValue; + } + + /** + * Returns the size of this field, with a minimum width of 14. + */ + protected override get size_() { + const s = super.size_; + if (s.width < 14) { + s.width = 14; + } + + return s; + } /** * @param value The initial value of the field. Should cast to a string. @@ -149,9 +167,13 @@ export abstract class FieldInput extends Field< if (this.isFullBlockField()) { this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); } + + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyInputField'); + } } - protected override isFullBlockField(): boolean { + override isFullBlockField(): boolean { const block = this.getSourceBlock(); if (!block) throw new UnattachedFieldError(); @@ -330,8 +352,16 @@ export abstract class FieldInput extends Field< * undefined if triggered programmatically. * @param quietInput True if editor should be created without focus. * Defaults to false. + * @param manageEphemeralFocus Whether ephemeral focus should be managed as + * part of the editor's inline editor (when the inline editor is shown). + * Callers must manage ephemeral focus themselves if this is false. + * Defaults to true. */ - protected override showEditor_(_e?: Event, quietInput = false) { + protected override showEditor_( + _e?: Event, + quietInput = false, + manageEphemeralFocus: boolean = true, + ) { this.workspace_ = (this.sourceBlock_ as BlockSvg).workspace; if ( !quietInput && @@ -340,7 +370,7 @@ export abstract class FieldInput extends Field< ) { this.showPromptEditor(); } else { - this.showInlineEditor(quietInput); + this.showInlineEditor(quietInput, manageEphemeralFocus); } } @@ -367,8 +397,10 @@ export abstract class FieldInput extends Field< * Create and show a text input editor that sits directly over the text input. * * @param quietInput True if editor should be created without focus. + * @param manageEphemeralFocus Whether ephemeral focus should be managed as + * part of the field's inline editor (widget div). */ - private showInlineEditor(quietInput: boolean) { + private showInlineEditor(quietInput: boolean, manageEphemeralFocus: boolean) { const block = this.getSourceBlock(); if (!block) { throw new UnattachedFieldError(); @@ -378,6 +410,7 @@ export abstract class FieldInput extends Field< block.RTL, this.widgetDispose_.bind(this), this.workspace_, + manageEphemeralFocus, ); this.htmlInput_ = this.widgetCreate_() as HTMLInputElement; this.isBeingEdited_ = true; @@ -406,7 +439,7 @@ export abstract class FieldInput extends Field< const clickTarget = this.getClickTarget_(); if (!clickTarget) throw new Error('A click target has not been set.'); - dom.addClass(clickTarget, 'editing'); + dom.addClass(clickTarget, 'blocklyEditing'); const htmlInput = document.createElement('input'); htmlInput.className = 'blocklyHtmlInput'; @@ -416,7 +449,7 @@ export abstract class FieldInput extends Field< 'spellcheck', this.spellcheck_ as AnyDuringMigration, ); - const scale = this.workspace_!.getScale(); + const scale = this.workspace_!.getAbsoluteScale(); const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt'; div!.style.fontSize = fontSize; htmlInput.style.fontSize = fontSize; @@ -501,7 +534,7 @@ export abstract class FieldInput extends Field< const clickTarget = this.getClickTarget_(); if (!clickTarget) throw new Error('A click target has not been set.'); - dom.removeClass(clickTarget, 'editing'); + dom.removeClass(clickTarget, 'blocklyEditing'); } /** @@ -562,10 +595,27 @@ export abstract class FieldInput extends Field< WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); } else if (e.key === 'Tab') { - WidgetDiv.hideIfOwner(this); - dropDownDiv.hideWithoutAnimation(); - (this.sourceBlock_ as BlockSvg).tab(this, !e.shiftKey); e.preventDefault(); + const cursor = this.workspace_?.getCursor(); + + const isValidDestination = (node: IFocusableNode | null) => + (node instanceof FieldInput || + (node instanceof BlockSvg && node.isSimpleReporter())) && + node !== this.getSourceBlock(); + + let target = e.shiftKey + ? cursor?.getPreviousNode(this, isValidDestination, false) + : cursor?.getNextNode(this, isValidDestination, false); + target = + target instanceof BlockSvg && target.isSimpleReporter() + ? target.getFields().next().value + : target; + + if (target instanceof FieldInput) { + WidgetDiv.hideIfOwner(this); + dropDownDiv.hideWithoutAnimation(); + target.showEditor(); + } } } @@ -673,15 +723,6 @@ export abstract class FieldInput extends Field< return true; } - /** - * Returns whether or not the field is tab navigable. - * - * @returns True if the field is tab navigable. - */ - override isTabNavigable(): boolean { - return true; - } - /** * Use the `getText_` developer hook to override the field's text * representation. When we're currently editing, return the current HTML value diff --git a/core/field_label.ts b/core/field_label.ts index 2b0ae1eba49..236154cc7b1 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -74,6 +74,9 @@ export class FieldLabel extends Field { if (this.class) { dom.addClass(this.getTextElement(), this.class); } + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyLabelField'); + } } /** diff --git a/core/field_number.ts b/core/field_number.ts index 0641b9ae32b..7e36591753e 100644 --- a/core/field_number.ts +++ b/core/field_number.ts @@ -19,6 +19,7 @@ import { } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; import * as aria from './utils/aria.js'; +import * as dom from './utils/dom.js'; /** * Class for an editable number field. @@ -307,6 +308,19 @@ export class FieldNumber extends FieldInput { return htmlInput; } + /** + * Initialize the field's DOM. + * + * @override + */ + + public override initView() { + super.initView(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyNumberField'); + } + } + /** * Construct a FieldNumber from a JSON arg object. * diff --git a/core/field_textinput.ts b/core/field_textinput.ts index 39bdca97056..2b896ad47be 100644 --- a/core/field_textinput.ts +++ b/core/field_textinput.ts @@ -21,6 +21,7 @@ import { FieldInputValidator, } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; +import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; /** @@ -49,6 +50,13 @@ export class FieldTextInput extends FieldInput { super(value, validator, config); } + override initView() { + super.initView(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyTextInputField'); + } + } + /** * Ensure that the input value casts to a valid string. * diff --git a/core/field_variable.ts b/core/field_variable.ts index 539557256b6..aa4fdfe310f 100644 --- a/core/field_variable.ts +++ b/core/field_variable.ts @@ -23,13 +23,14 @@ import { MenuOption, } from './field_dropdown.js'; import * as fieldRegistry from './field_registry.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import * as internalConstants from './internal_constants.js'; import type {Menu} from './menu.js'; import type {MenuItem} from './menuitem.js'; import {Msg} from './msg.js'; +import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; -import {VariableModel} from './variable_model.js'; import * as Variables from './variables.js'; import * as Xml from './xml.js'; @@ -48,10 +49,9 @@ export class FieldVariable extends FieldDropdown { * dropdown. */ variableTypes: string[] | null = []; - protected override size_: Size; /** The variable model associated with this field. */ - private variable: VariableModel | null = null; + private variable: IVariableModel | null = null; /** * Serializable fields are saved by the serializer, non-serializable fields @@ -69,7 +69,8 @@ export class FieldVariable extends FieldDropdown { * field's value. Takes in a variable ID & returns a validated variable * ID, or null to abort the change. * @param variableTypes A list of the types of variables to include in the - * dropdown. Will only be used if config is not provided. + * dropdown. Pass `null` to include all types that exist on the + * workspace. Will only be used if config is not provided. * @param defaultType The type of variable to create if this field's value * is not explicitly set. Defaults to ''. Will only be used if config * is not provided. @@ -81,7 +82,7 @@ export class FieldVariable extends FieldDropdown { constructor( varName: string | null | typeof Field.SKIP_SETUP, validator?: FieldVariableValidator, - variableTypes?: string[], + variableTypes?: string[] | null, defaultType?: string, config?: FieldVariableConfig, ) { @@ -148,6 +149,11 @@ export class FieldVariable extends FieldDropdown { this.doValueUpdate_(variable.getId()); } + override initView() { + super.initView(); + dom.addClass(this.fieldGroup_!, 'blocklyVariableField'); + } + override shouldAddBorderRect_() { const block = this.getSourceBlock(); if (!block) { @@ -190,12 +196,12 @@ export class FieldVariable extends FieldDropdown { ); // This should never happen :) - if (variableType !== null && variableType !== variable.type) { + if (variableType !== null && variableType !== variable.getType()) { throw Error( "Serialized variable type with id '" + variable.getId() + "' had type " + - variable.type + + variable.getType() + ', and ' + 'does not match variable field that references it: ' + Xml.domToText(fieldElement) + @@ -218,9 +224,9 @@ export class FieldVariable extends FieldDropdown { this.initModel(); fieldElement.id = this.variable!.getId(); - fieldElement.textContent = this.variable!.name; - if (this.variable!.type) { - fieldElement.setAttribute('variabletype', this.variable!.type); + fieldElement.textContent = this.variable!.getName(); + if (this.variable!.getType()) { + fieldElement.setAttribute('variabletype', this.variable!.getType()); } return fieldElement; } @@ -243,8 +249,8 @@ export class FieldVariable extends FieldDropdown { this.initModel(); const state = {'id': this.variable!.getId()}; if (doFullSerialization) { - (state as AnyDuringMigration)['name'] = this.variable!.name; - (state as AnyDuringMigration)['type'] = this.variable!.type; + (state as AnyDuringMigration)['name'] = this.variable!.getName(); + (state as AnyDuringMigration)['type'] = this.variable!.getType(); } return state; } @@ -301,7 +307,7 @@ export class FieldVariable extends FieldDropdown { * is selected. */ override getText(): string { - return this.variable ? this.variable.name : ''; + return this.variable ? this.variable.getName() : ''; } /** @@ -312,10 +318,19 @@ export class FieldVariable extends FieldDropdown { * @returns The selected variable, or null if none was selected. * @internal */ - getVariable(): VariableModel | null { + getVariable(): IVariableModel | null { return this.variable; } + /** + * Gets the type of this field's default variable. + * + * @returns The default type for this variable field. + */ + protected getDefaultType(): string { + return this.defaultType; + } + /** * Gets the validation function for this field, or null if not set. * Returns null if the variable is not set, because validators should not @@ -359,7 +374,7 @@ export class FieldVariable extends FieldDropdown { return null; } // Type Checks. - const type = variable.type; + const type = variable.getType(); if (!this.typeIsAllowed(type)) { console.warn("Variable type doesn't match this field! Type was " + type); return null; @@ -407,25 +422,27 @@ export class FieldVariable extends FieldDropdown { * Return a list of variable types to include in the dropdown. * * @returns Array of variable types. - * @throws {Error} if variableTypes is an empty array. */ private getVariableTypes(): string[] { - let variableTypes = this.variableTypes; - if (variableTypes === null) { - // If variableTypes is null, return all variable types. - if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { - return this.sourceBlock_.workspace.getVariableTypes(); - } + if (this.variableTypes) return this.variableTypes; + + if (!this.sourceBlock_ || this.sourceBlock_.isDeadOrDying()) { + // We should include all types in the block's workspace, + // but the block is dead so just give up. + return ['']; } - variableTypes = variableTypes || ['']; - if (variableTypes.length === 0) { - // Throw an error if variableTypes is an empty list. - const name = this.getText(); - throw Error( - "'variableTypes' of field variable " + name + ' was an empty list', - ); + + // If variableTypes is null, return all variable types in the workspace. + let allTypes = this.sourceBlock_.workspace.getVariableMap().getTypes(); + if (this.sourceBlock_.isInFlyout) { + // If this block is in a flyout, we also need to check the potential variables + const potentialMap = + this.sourceBlock_.workspace.getPotentialVariableMap(); + if (!potentialMap) return allTypes; + allTypes = Array.from(new Set([...allTypes, ...potentialMap.getTypes()])); } - return variableTypes; + + return allTypes; } /** @@ -439,11 +456,15 @@ export class FieldVariable extends FieldDropdown { * value is not explicitly set. Defaults to ''. */ private setTypes(variableTypes: string[] | null = null, defaultType = '') { - // If you expected that the default type would be the same as the only entry - // in the variable types array, tell the Blockly team by commenting on - // #1499. - // Set the allowable variable types. Null means all types on the workspace. + const name = this.getText(); if (Array.isArray(variableTypes)) { + if (variableTypes.length === 0) { + // Throw an error if variableTypes is an empty list. + throw Error( + `'variableTypes' of field variable ${name} was an empty list. If you want to include all variable types, pass 'null' instead.`, + ); + } + // Make sure the default type is valid. let isInArray = false; for (let i = 0; i < variableTypes.length; i++) { @@ -461,8 +482,7 @@ export class FieldVariable extends FieldDropdown { } } else if (variableTypes !== null) { throw Error( - "'variableTypes' was not an array in the definition of " + - 'a FieldVariable', + `'variableTypes' was not an array or null in the definition of FieldVariable ${name}`, ); } // Only update the field once all checks pass. @@ -493,16 +513,14 @@ export class FieldVariable extends FieldDropdown { const id = menuItem.getValue(); // Handle special cases. if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { - if (id === internalConstants.RENAME_VARIABLE_ID) { + if (id === internalConstants.RENAME_VARIABLE_ID && this.variable) { // Rename variable. - Variables.renameVariable( - this.sourceBlock_.workspace, - this.variable as VariableModel, - ); + Variables.renameVariable(this.sourceBlock_.workspace, this.variable); return; - } else if (id === internalConstants.DELETE_VARIABLE_ID) { + } else if (id === internalConstants.DELETE_VARIABLE_ID && this.variable) { // Delete variable. - this.sourceBlock_.workspace.deleteVariableById(this.variable!.getId()); + const workspace = this.variable.getWorkspace(); + Variables.deleteVariable(workspace, this.variable, this.sourceBlock_); return; } } @@ -554,24 +572,37 @@ export class FieldVariable extends FieldDropdown { ); } const name = this.getText(); - let variableModelList: VariableModel[] = []; - if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { + let variableModelList: IVariableModel[] = []; + const sourceBlock = this.getSourceBlock(); + if (sourceBlock && !sourceBlock.isDeadOrDying()) { + const workspace = sourceBlock.workspace; const variableTypes = this.getVariableTypes(); // Get a copy of the list, so that adding rename and new variable options // doesn't modify the workspace's list. for (let i = 0; i < variableTypes.length; i++) { const variableType = variableTypes[i]; - const variables = - this.sourceBlock_.workspace.getVariablesOfType(variableType); + const variables = workspace + .getVariableMap() + .getVariablesOfType(variableType); variableModelList = variableModelList.concat(variables); + if (workspace.isFlyout) { + variableModelList = variableModelList.concat( + workspace + .getPotentialVariableMap() + ?.getVariablesOfType(variableType) ?? [], + ); + } } } - variableModelList.sort(VariableModel.compareByName); + variableModelList.sort(Variables.compareByName); const options: [string, string][] = []; for (let i = 0; i < variableModelList.length; i++) { // Set the UUID as the internal representation of the variable. - options[i] = [variableModelList[i].name, variableModelList[i].getId()]; + options[i] = [ + variableModelList[i].getName(), + variableModelList[i].getId(), + ]; } options.push([ Msg['RENAME_VARIABLE'], diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 96d2b27fdcb..9f94ec30905 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -11,53 +11,43 @@ */ // Former goog.module ID: Blockly.Flyout -import type {Block} from './block.js'; import {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; -import * as common from './common.js'; import {ComponentManager} from './component_manager.js'; -import {MANUALLY_DISABLED} from './constants.js'; import {DeleteArea} from './delete_area.js'; import type {Abstract as AbstractEvent} from './events/events_abstract.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; -import {FlyoutButton} from './flyout_button.js'; +import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; +import {FlyoutNavigator} from './flyout_navigator.js'; +import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; +import {getFocusManager} from './focus_manager.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {Options} from './options.js'; +import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; import {ScrollbarPair} from './scrollbar_pair.js'; +import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; import * as blocks from './serialization/blocks.js'; -import * as Tooltip from './tooltip.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; import {Svg} from './utils/svg.js'; import * as toolbox from './utils/toolbox.js'; -import * as utilsXml from './utils/xml.js'; import * as Variables from './variables.js'; import {WorkspaceSvg} from './workspace_svg.js'; -import * as Xml from './xml.js'; - -enum FlyoutItemType { - BLOCK = 'block', - BUTTON = 'button', -} - -/** - * The language-neutral ID for when the reason why a block is disabled is - * because the workspace is at block capacity. - */ -const WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON = - 'WORKSPACE_AT_BLOCK_CAPACITY'; /** * Class for a flyout. */ export abstract class Flyout extends DeleteArea - implements IAutoHideable, IFlyout + implements IAutoHideable, IFlyout, IFocusableNode { /** * Position the flyout. @@ -85,12 +75,11 @@ export abstract class Flyout protected abstract setMetrics_(xyRatio: {x?: number; y?: number}): void; /** - * Lay out the blocks in the flyout. + * Lay out the elements in the flyout. * - * @param contents The blocks and buttons to lay out. - * @param gaps The visible gaps between blocks. + * @param contents The flyout elements to lay out. */ - protected abstract layout_(contents: FlyoutItem[], gaps: number[]): void; + protected abstract layout_(contents: FlyoutItem[]): void; /** * Scroll the flyout. @@ -100,8 +89,8 @@ export abstract class Flyout protected abstract wheel_(e: WheelEvent): void; /** - * Compute height of flyout. Position mat under each block. - * For RTL: Lay out the blocks right-aligned. + * Compute bounds of flyout. + * For RTL: Lay out the elements right-aligned. */ protected abstract reflowInternal_(): void; @@ -124,11 +113,6 @@ export abstract class Flyout */ abstract scrollToStart(): void; - /** - * The type of a flyout content item. - */ - static FlyoutItemType = FlyoutItemType; - protected workspace_: WorkspaceSvg; RTL: boolean; /** @@ -148,43 +132,15 @@ export abstract class Flyout /** * Function that will be registered as a change listener on the workspace - * to reflow when blocks in the flyout workspace change. + * to reflow when elements in the flyout workspace change. */ private reflowWrapper: ((e: AbstractEvent) => void) | null = null; /** - * Function that disables blocks in the flyout based on max block counts - * allowed in the target workspace. Registered as a change listener on the - * target workspace. - */ - private filterWrapper: ((e: AbstractEvent) => void) | null = null; - - /** - * List of background mats that lurk behind each block to catch clicks - * landing in the blocks' lakes and bays. - */ - private mats: SVGElement[] = []; - - /** - * List of visible buttons. - */ - protected buttons_: FlyoutButton[] = []; - - /** - * List of visible buttons and blocks. + * List of flyout elements. */ protected contents: FlyoutItem[] = []; - /** - * List of event listeners. - */ - private listeners: browserEvents.Data[] = []; - - /** - * List of blocks that should always be disabled. - */ - private permanentlyDisabled: Block[] = []; - protected readonly tabWidth_: number; /** @@ -194,11 +150,6 @@ export abstract class Flyout */ targetWorkspace!: WorkspaceSvg; - /** - * A list of blocks that can be reused. - */ - private recycledBlocks: BlockSvg[] = []; - /** * Does the flyout automatically close when a block is created? */ @@ -213,7 +164,6 @@ export abstract class Flyout * Whether the workspace containing this flyout is visible. */ private containerVisible = true; - protected rectMap_: WeakMap; /** * Corner radius of the flyout background. @@ -271,6 +221,13 @@ export abstract class Flyout * The root SVG group for the button or label. */ protected svgGroup_: SVGGElement | null = null; + + /** + * Map from flyout content type to the corresponding inflater class + * responsible for creating concrete instances of the content type. + */ + protected inflaters = new Map(); + /** * @param workspaceOptions Dictionary of options for the * workspace. @@ -287,6 +244,7 @@ export abstract class Flyout this.workspace_.internalIsFlyout = true; // Keep the workspace visibility consistent with the flyout's visibility. this.workspace_.setVisible(this.visible); + this.workspace_.setNavigator(new FlyoutNavigator(this)); /** * The unique id for this component that is used to register with the @@ -310,15 +268,7 @@ export abstract class Flyout this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH; /** - * A map from blocks to the rects which are beneath them to act as input - * targets. - * - * @internal - */ - this.rectMap_ = new WeakMap(); - - /** - * Margin around the edges of the blocks in the flyout. + * Margin around the edges of the elements in the flyout. */ this.MARGIN = this.CORNER_RADIUS; @@ -358,6 +308,7 @@ export abstract class Flyout // hide/show code will set up proper visibility and size later. this.svgGroup_ = dom.createSvgElement(tagName, { 'class': 'blocklyFlyout', + 'tabindex': '0', }); this.svgGroup_.style.display = 'none'; this.svgBackground_ = dom.createSvgElement( @@ -372,6 +323,9 @@ export abstract class Flyout this.workspace_ .getThemeManager() .subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); + + getFocusManager().registerTree(this); + return this.svgGroup_; } @@ -403,8 +357,6 @@ export abstract class Flyout this.wheel_, ), ); - this.filterWrapper = this.filterForCapacity.bind(this); - this.targetWorkspace.addChangeListener(this.filterWrapper); // Dragging the flyout up and down. this.boundEvents.push( @@ -448,9 +400,6 @@ export abstract class Flyout browserEvents.unbind(event); } this.boundEvents.length = 0; - if (this.filterWrapper) { - this.targetWorkspace.removeChangeListener(this.filterWrapper); - } if (this.workspace_) { this.workspace_.getThemeManager().unsubscribe(this.svgBackground_!); this.workspace_.dispose(); @@ -458,6 +407,7 @@ export abstract class Flyout if (this.svgGroup_) { dom.removeNode(this.svgGroup_); } + getFocusManager().unregisterTree(this); } /** @@ -570,16 +520,16 @@ export abstract class Flyout } /** - * Get the list of buttons and blocks of the current flyout. + * Get the list of elements of the current flyout. * - * @returns The array of flyout buttons and blocks. + * @returns The array of flyout elements. */ getContents(): FlyoutItem[] { return this.contents; } /** - * Store the list of buttons and blocks on the flyout. + * Store the list of elements on the flyout. * * @param contents - The array of items for the flyout. */ @@ -654,16 +604,11 @@ export abstract class Flyout return; } this.setVisible(false); - // Delete all the event listeners. - for (const listen of this.listeners) { - browserEvents.unbind(listen); - } - this.listeners.length = 0; if (this.reflowWrapper) { this.workspace_.removeChangeListener(this.reflowWrapper); this.reflowWrapper = null; } - // Do NOT delete the blocks here. Wait until Flyout.show. + // Do NOT delete the flyout contents here. Wait until Flyout.show. // https://neil.fraser.name/news/2014/08/09/ } @@ -691,26 +636,30 @@ export abstract class Flyout renderManagement.triggerQueuedRenders(this.workspace_); - this.setContents(flyoutInfo.contents); + this.setContents(flyoutInfo); - this.layout_(flyoutInfo.contents, flyoutInfo.gaps); + this.layout_(flyoutInfo); if (this.horizontalLayout) { this.height_ = 0; } else { this.width_ = 0; } - this.workspace_.setResizesEnabled(true); this.reflow(); + this.workspace_.setResizesEnabled(true); - this.filterForCapacity(); - - // Correctly position the flyout's scrollbar when it opens. - this.position(); - - this.reflowWrapper = this.reflow.bind(this); + // Listen for block change events, and reflow the flyout in response. This + // accommodates e.g. resizing a non-autoclosing flyout in response to the + // user typing long strings into fields on the blocks in the flyout. + this.reflowWrapper = (event) => { + if ( + event.type === EventType.BLOCK_CHANGE || + event.type === EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE + ) { + this.reflow(); + } + }; this.workspace_.addChangeListener(this.reflowWrapper); - this.emptyRecycledBlocks(); } /** @@ -719,15 +668,12 @@ export abstract class Flyout * * @param parsedContent The array * of objects to show in the flyout. - * @returns The list of contents and gaps needed to lay out the flyout. + * @returns The list of contents needed to lay out the flyout. */ - private createFlyoutInfo(parsedContent: toolbox.FlyoutItemInfoArray): { - contents: FlyoutItem[]; - gaps: number[]; - } { + private createFlyoutInfo( + parsedContent: toolbox.FlyoutItemInfoArray, + ): FlyoutItem[] { const contents: FlyoutItem[] = []; - const gaps: number[] = []; - this.permanentlyDisabled.length = 0; const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y; for (const info of parsedContent) { if ('custom' in info) { @@ -736,44 +682,59 @@ export abstract class Flyout const flyoutDef = this.getDynamicCategoryContents(categoryName); const parsedDynamicContent = toolbox.convertFlyoutDefToJsonArray(flyoutDef); - const {contents: dynamicContents, gaps: dynamicGaps} = - this.createFlyoutInfo(parsedDynamicContent); - contents.push(...dynamicContents); - gaps.push(...dynamicGaps); + contents.push(...this.createFlyoutInfo(parsedDynamicContent)); } - switch (info['kind'].toUpperCase()) { - case 'BLOCK': { - const blockInfo = info as toolbox.BlockInfo; - const block = this.createFlyoutBlock(blockInfo); - contents.push({type: FlyoutItemType.BLOCK, block: block}); - this.addBlockGap(blockInfo, gaps, defaultGap); - break; - } - case 'SEP': { - const sepInfo = info as toolbox.SeparatorInfo; - this.addSeparatorGap(sepInfo, gaps, defaultGap); - break; - } - case 'LABEL': { - const labelInfo = info as toolbox.LabelInfo; - // A label is a button with different styling. - const label = this.createButton(labelInfo, /** isLabel */ true); - contents.push({type: FlyoutItemType.BUTTON, button: label}); - gaps.push(defaultGap); - break; - } - case 'BUTTON': { - const buttonInfo = info as toolbox.ButtonInfo; - const button = this.createButton(buttonInfo, /** isLabel */ false); - contents.push({type: FlyoutItemType.BUTTON, button: button}); - gaps.push(defaultGap); - break; + const type = info['kind'].toLowerCase(); + const inflater = this.getInflaterForType(type); + if (inflater) { + contents.push(inflater.load(info, this)); + const gap = inflater.gapForItem(info, defaultGap); + if (gap) { + contents.push( + new FlyoutItem( + new FlyoutSeparator( + gap, + this.horizontalLayout ? SeparatorAxis.X : SeparatorAxis.Y, + ), + SEPARATOR_TYPE, + ), + ); } } } - return {contents: contents, gaps: gaps}; + return this.normalizeSeparators(contents); + } + + /** + * Updates and returns the provided list of flyout contents to flatten + * separators as needed. + * + * When multiple separators occur one after another, the value of the last one + * takes precedence and the earlier separators in the group are removed. + * + * @param contents The list of flyout contents to flatten separators in. + * @returns An updated list of flyout contents with only one separator between + * each non-separator item. + */ + protected normalizeSeparators(contents: FlyoutItem[]): FlyoutItem[] { + for (let i = contents.length - 1; i > 0; i--) { + const elementType = contents[i].getType().toLowerCase(); + const previousElementType = contents[i - 1].getType().toLowerCase(); + if ( + elementType === SEPARATOR_TYPE && + previousElementType === SEPARATOR_TYPE + ) { + // Remove previousElement from the array, shifting the current element + // forward as a result. This preserves the behavior where explicit + // separator elements override the value of prior implicit (or explicit) + // separator elements. + contents.splice(i - 1, 1); + } + } + + return contents; } /** @@ -800,287 +761,18 @@ export abstract class Flyout } /** - * Creates a flyout button or a flyout label. - * - * @param btnInfo The object holding information about a button or a label. - * @param isLabel True if the button is a label, false otherwise. - * @returns The object used to display the button in the - * flyout. - */ - private createButton( - btnInfo: toolbox.ButtonOrLabelInfo, - isLabel: boolean, - ): FlyoutButton { - const curButton = new FlyoutButton( - this.workspace_, - this.targetWorkspace as WorkspaceSvg, - btnInfo, - isLabel, - ); - return curButton; - } - - /** - * Create a block from the xml and permanently disable any blocks that were - * defined as disabled. - * - * @param blockInfo The info of the block. - * @returns The block created from the blockInfo. - */ - private createFlyoutBlock(blockInfo: toolbox.BlockInfo): BlockSvg { - let block; - if (blockInfo['blockxml']) { - const xml = ( - typeof blockInfo['blockxml'] === 'string' - ? utilsXml.textToDom(blockInfo['blockxml']) - : blockInfo['blockxml'] - ) as Element; - block = this.getRecycledBlock(xml.getAttribute('type')!); - if (!block) { - block = Xml.domToBlockInternal(xml, this.workspace_); - } - } else { - block = this.getRecycledBlock(blockInfo['type']!); - if (!block) { - if (blockInfo['enabled'] === undefined) { - blockInfo['enabled'] = - blockInfo['disabled'] !== 'true' && blockInfo['disabled'] !== true; - } - if ( - blockInfo['disabledReasons'] === undefined && - blockInfo['enabled'] === false - ) { - blockInfo['disabledReasons'] = [MANUALLY_DISABLED]; - } - block = blocks.appendInternal( - blockInfo as blocks.State, - this.workspace_, - ); - } - } - - if (!block.isEnabled()) { - // Record blocks that were initially disabled. - // Do not enable these blocks as a result of capacity filtering. - this.permanentlyDisabled.push(block); - } - return block as BlockSvg; - } - - /** - * Returns a block from the array of recycled blocks with the given type, or - * undefined if one cannot be found. - * - * @param blockType The type of the block to try to recycle. - * @returns The recycled block, or undefined if - * one could not be recycled. - */ - private getRecycledBlock(blockType: string): BlockSvg | undefined { - let index = -1; - for (let i = 0; i < this.recycledBlocks.length; i++) { - if (this.recycledBlocks[i].type === blockType) { - index = i; - break; - } - } - return index === -1 ? undefined : this.recycledBlocks.splice(index, 1)[0]; - } - - /** - * Adds a gap in the flyout based on block info. - * - * @param blockInfo Information about a block. - * @param gaps The list of gaps between items in the flyout. - * @param defaultGap The default gap between one element and the - * next. - */ - private addBlockGap( - blockInfo: toolbox.BlockInfo, - gaps: number[], - defaultGap: number, - ) { - let gap; - if (blockInfo['gap']) { - gap = parseInt(String(blockInfo['gap'])); - } else if (blockInfo['blockxml']) { - const xml = ( - typeof blockInfo['blockxml'] === 'string' - ? utilsXml.textToDom(blockInfo['blockxml']) - : blockInfo['blockxml'] - ) as Element; - gap = parseInt(xml.getAttribute('gap')!); - } - gaps.push(!gap || isNaN(gap) ? defaultGap : gap); - } - - /** - * Add the necessary gap in the flyout for a separator. - * - * @param sepInfo The object holding - * information about a separator. - * @param gaps The list gaps between items in the flyout. - * @param defaultGap The default gap between the button and next - * element. - */ - private addSeparatorGap( - sepInfo: toolbox.SeparatorInfo, - gaps: number[], - defaultGap: number, - ) { - // Change the gap between two toolbox elements. - // - // The default gap is 24, can be set larger or smaller. - // This overwrites the gap attribute on the previous element. - const newGap = parseInt(String(sepInfo['gap'])); - // Ignore gaps before the first block. - if (!isNaN(newGap) && gaps.length > 0) { - gaps[gaps.length - 1] = newGap; - } else { - gaps.push(defaultGap); - } - } - - /** - * Delete blocks, mats and buttons from a previous showing of the flyout. + * Delete elements from a previous showing of the flyout. */ private clearOldBlocks() { - // Delete any blocks from a previous showing. - const oldBlocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = oldBlocks[i]); i++) { - if (this.blockIsRecyclable_(block)) { - this.recycleBlock(block); - } else { - block.dispose(false, false); - } - } - // Delete any mats from a previous showing. - for (let j = 0; j < this.mats.length; j++) { - const rect = this.mats[j]; - if (rect) { - Tooltip.unbindMouseEvents(rect); - dom.removeNode(rect); - } - } - this.mats.length = 0; - // Delete any buttons from a previous showing. - for (let i = 0, button; (button = this.buttons_[i]); i++) { - button.dispose(); - } - this.buttons_.length = 0; + this.getContents().forEach((item) => { + const inflater = this.getInflaterForType(item.getType()); + inflater?.disposeItem(item); + }); // Clear potential variables from the previous showing. this.workspace_.getPotentialVariableMap()?.clear(); } - /** - * Empties all of the recycled blocks, properly disposing of them. - */ - private emptyRecycledBlocks() { - for (let i = 0; i < this.recycledBlocks.length; i++) { - this.recycledBlocks[i].dispose(); - } - this.recycledBlocks = []; - } - - /** - * Returns whether the given block can be recycled or not. - * - * @param _block The block to check for recyclability. - * @returns True if the block can be recycled. False otherwise. - */ - protected blockIsRecyclable_(_block: BlockSvg): boolean { - // By default, recycling is disabled. - return false; - } - - /** - * Puts a previously created block into the recycle bin and moves it to the - * top of the workspace. Used during large workspace swaps to limit the number - * of new DOM elements we need to create. - * - * @param block The block to recycle. - */ - private recycleBlock(block: BlockSvg) { - const xy = block.getRelativeToSurfaceXY(); - block.moveBy(-xy.x, -xy.y); - this.recycledBlocks.push(block); - } - - /** - * Add listeners to a block that has been added to the flyout. - * - * @param root The root node of the SVG group the block is in. - * @param block The block to add listeners for. - * @param rect The invisible rectangle under the block that acts - * as a mat for that block. - */ - protected addBlockListeners_( - root: SVGElement, - block: BlockSvg, - rect: SVGElement, - ) { - this.listeners.push( - browserEvents.conditionalBind( - root, - 'pointerdown', - null, - this.blockMouseDown(block), - ), - ); - this.listeners.push( - browserEvents.conditionalBind( - rect, - 'pointerdown', - null, - this.blockMouseDown(block), - ), - ); - this.listeners.push( - browserEvents.bind(root, 'pointerenter', block, () => { - if (!this.targetWorkspace.isDragging()) { - block.addSelect(); - } - }), - ); - this.listeners.push( - browserEvents.bind(root, 'pointerleave', block, () => { - if (!this.targetWorkspace.isDragging()) { - block.removeSelect(); - } - }), - ); - this.listeners.push( - browserEvents.bind(rect, 'pointerenter', block, () => { - if (!this.targetWorkspace.isDragging()) { - block.addSelect(); - } - }), - ); - this.listeners.push( - browserEvents.bind(rect, 'pointerleave', block, () => { - if (!this.targetWorkspace.isDragging()) { - block.removeSelect(); - } - }), - ); - } - - /** - * Handle a pointerdown on an SVG block in a non-closing flyout. - * - * @param block The flyout block to copy. - * @returns Function to call when block is clicked. - */ - private blockMouseDown(block: BlockSvg) { - return (e: PointerEvent) => { - const gesture = this.targetWorkspace.getGesture(e); - if (gesture) { - gesture.setStartBlock(block); - gesture.handleFlyoutStart(e, this); - } - }; - } - /** * Pointer down on the flyout background. Start a vertical scroll drag. * @@ -1103,7 +795,7 @@ export abstract class Flyout * @internal */ isBlockCreatable(block: BlockSvg): boolean { - return block.isEnabled(); + return block.isEnabled() && !this.getTargetWorkspace().isReadOnly(); } /** @@ -1149,123 +841,12 @@ export abstract class Flyout } if (this.autoClose) { this.hide(); - } else { - this.filterForCapacity(); } return newBlock; } /** - * Initialize the given button: move it to the correct location, - * add listeners, etc. - * - * @param button The button to initialize and place. - * @param x The x position of the cursor during this layout pass. - * @param y The y position of the cursor during this layout pass. - */ - protected initFlyoutButton_(button: FlyoutButton, x: number, y: number) { - const buttonSvg = button.createDom(); - button.moveTo(x, y); - button.show(); - // Clicking on a flyout button or label is a lot like clicking on the - // flyout background. - this.listeners.push( - browserEvents.conditionalBind( - buttonSvg, - 'pointerdown', - this, - this.onMouseDown, - ), - ); - - this.buttons_.push(button); - } - - /** - * Create and place a rectangle corresponding to the given block. - * - * @param block The block to associate the rect to. - * @param x The x position of the cursor during this layout pass. - * @param y The y position of the cursor during this layout pass. - * @param blockHW The height and width of - * the block. - * @param index The index into the mats list where this rect should - * be placed. - * @returns Newly created SVG element for the rectangle behind - * the block. - */ - protected createRect_( - block: BlockSvg, - x: number, - y: number, - blockHW: {height: number; width: number}, - index: number, - ): SVGElement { - // Create an invisible rectangle under the block to act as a button. Just - // using the block as a button is poor, since blocks have holes in them. - const rect = dom.createSvgElement(Svg.RECT, { - 'fill-opacity': 0, - 'x': x, - 'y': y, - 'height': blockHW.height, - 'width': blockHW.width, - }); - (rect as AnyDuringMigration).tooltip = block; - Tooltip.bindMouseEvents(rect); - // Add the rectangles under the blocks, so that the blocks' tooltips work. - this.workspace_.getCanvas().insertBefore(rect, block.getSvgRoot()); - - this.rectMap_.set(block, rect); - this.mats[index] = rect; - return rect; - } - - /** - * Move a rectangle to sit exactly behind a block, taking into account tabs, - * hats, and any other protrusions we invent. - * - * @param rect The rectangle to move directly behind the block. - * @param block The block the rectangle should be behind. - */ - protected moveRectToBlock_(rect: SVGElement, block: BlockSvg) { - const blockHW = block.getHeightWidth(); - rect.setAttribute('width', String(blockHW.width)); - rect.setAttribute('height', String(blockHW.height)); - - const blockXY = block.getRelativeToSurfaceXY(); - rect.setAttribute('y', String(blockXY.y)); - rect.setAttribute( - 'x', - String(this.RTL ? blockXY.x - blockHW.width : blockXY.x), - ); - } - - /** - * Filter the blocks on the flyout to disable the ones that are above the - * capacity limit. For instance, if the user may only place two more blocks - * on the workspace, an "a + b" block that has two shadow blocks would be - * disabled. - */ - private filterForCapacity() { - const blocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = blocks[i]); i++) { - if (!this.permanentlyDisabled.includes(block)) { - const enable = this.targetWorkspace.isCapacityAvailable( - common.getBlockTypeCounts(block), - ); - while (block) { - block.setDisabledReason( - !enable, - WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON, - ); - block = block.getNextBlock(); - } - } - } - } - - /** - * Reflow blocks and their mats. + * Reflow flyout contents. */ reflow() { if (this.reflowWrapper) { @@ -1364,13 +945,93 @@ export abstract class Flyout // No 'reason' provided since events are disabled. block.moveTo(new Coordinate(finalOffset.x, finalOffset.y)); } -} -/** - * A flyout content item. - */ -export interface FlyoutItem { - type: FlyoutItemType; - button?: FlyoutButton | undefined; - block?: BlockSvg | undefined; + /** + * Returns the inflater responsible for constructing items of the given type. + * + * @param type The type of flyout content item to provide an inflater for. + * @returns An inflater object for the given type, or null if no inflater + * is registered for that type. + */ + protected getInflaterForType(type: string): IFlyoutInflater | null { + if (this.inflaters.has(type)) { + return this.inflaters.get(type) ?? null; + } + + const InflaterClass = registry.getClass( + registry.Type.FLYOUT_INFLATER, + type, + ); + if (InflaterClass) { + const inflater = new InflaterClass(); + this.inflaters.set(type, inflater); + return inflater; + } + + return null; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.'); + return this.svgGroup_; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode( + _previousNode: IFocusableNode | null, + ): IFocusableNode | null { + return null; + } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): Array { + return [this.workspace_]; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + lookUpFocusableNode(_id: string): IFocusableNode | null { + // No focusable node needs to be returned since the flyout's subtree is a + // workspace that will manage its own focusable state. + return null; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus( + _node: IFocusableNode, + _previousTree: IFocusableTree | null, + ): void {} + + /** See IFocusableTree.onTreeBlur. */ + onTreeBlur(nextTree: IFocusableTree | null): void { + const toolbox = this.targetWorkspace.getToolbox(); + // If focus is moving to either the toolbox or the flyout's workspace, do + // not close the flyout. For anything else, do close it since the flyout is + // no longer focused. + if (toolbox && nextTree === toolbox) return; + if (nextTree === this.workspace_) return; + if (toolbox) toolbox.clearSelection(); + this.autoHide(false); + } } diff --git a/core/flyout_button.ts b/core/flyout_button.ts index b03a8d9615c..823b57be765 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -11,12 +11,17 @@ */ // Former goog.module ID: Blockly.FlyoutButton -import type {IASTNodeLocationSvg} from './blockly.js'; import * as browserEvents from './browser_events.js'; import * as Css from './css.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import type {IRenderedElement} from './interfaces/i_rendered_element.js'; +import {idGenerator} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; +import {Rect} from './utils/rect.js'; import * as style from './utils/style.js'; import {Svg} from './utils/svg.js'; import type * as toolbox from './utils/toolbox.js'; @@ -25,7 +30,9 @@ import type {WorkspaceSvg} from './workspace_svg.js'; /** * Class for a button or label in the flyout. */ -export class FlyoutButton implements IASTNodeLocationSvg { +export class FlyoutButton + implements IBoundedElement, IRenderedElement, IFocusableNode +{ /** The horizontal margin around the text in the button. */ static TEXT_MARGIN_X = 5; @@ -41,7 +48,8 @@ export class FlyoutButton implements IASTNodeLocationSvg { private readonly cssClass: string | null; /** Mouse up event data. */ - private onMouseUpWrapper: browserEvents.Data | null = null; + private onMouseDownWrapper: browserEvents.Data; + private onMouseUpWrapper: browserEvents.Data; info: toolbox.ButtonOrLabelInfo; /** The width of the button's rect. */ @@ -51,7 +59,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { height = 0; /** The root SVG group for the button or label. */ - private svgGroup: SVGGElement | null = null; + private svgGroup: SVGGElement; /** The SVG element with the text of the label or button. */ private svgText: SVGTextElement | null = null; @@ -62,6 +70,9 @@ export class FlyoutButton implements IASTNodeLocationSvg { */ cursorSvg: SVGElement | null = null; + /** The unique ID for this FlyoutButton. */ + private id: string; + /** * @param workspace The workspace in which to place this button. * @param targetWorkspace The flyout's target workspace. @@ -92,14 +103,6 @@ export class FlyoutButton implements IASTNodeLocationSvg { /** The JSON specifying the label / button. */ this.info = json; - } - - /** - * Create the button elements. - * - * @returns The button's SVG group. - */ - createDom(): SVGElement { let cssClass = this.isFlyoutLabel ? 'blocklyFlyoutLabel' : 'blocklyFlyoutButton'; @@ -107,9 +110,10 @@ export class FlyoutButton implements IASTNodeLocationSvg { cssClass += ' ' + this.cssClass; } + this.id = idGenerator.getNextUniqueId(); this.svgGroup = dom.createSvgElement( Svg.G, - {'class': cssClass}, + {'id': this.id, 'class': cssClass, 'tabindex': '-1'}, this.workspace.getCanvas(), ); @@ -179,7 +183,7 @@ export class FlyoutButton implements IASTNodeLocationSvg { fontWeight, fontFamily, ); - this.height = fontMetrics.height; + this.height = this.height || fontMetrics.height; if (!this.isFlyoutLabel) { this.width += 2 * FlyoutButton.TEXT_MARGIN_X; @@ -198,15 +202,24 @@ export class FlyoutButton implements IASTNodeLocationSvg { this.updateTransform(); - // AnyDuringMigration because: Argument of type 'SVGGElement | null' is not - // assignable to parameter of type 'EventTarget'. + this.onMouseDownWrapper = browserEvents.conditionalBind( + this.svgGroup, + 'pointerdown', + this, + this.onMouseDown, + ); this.onMouseUpWrapper = browserEvents.conditionalBind( - this.svgGroup as AnyDuringMigration, + this.svgGroup, 'pointerup', this, this.onMouseUp, ); - return this.svgGroup!; + } + + createDom(): SVGElement { + // No-op, now handled in constructor. Will be removed in followup refactor + // PR that updates the flyout classes to use inflaters. + return this.svgGroup; } /** Correctly position the flyout button and make it visible. */ @@ -235,6 +248,17 @@ export class FlyoutButton implements IASTNodeLocationSvg { this.updateTransform(); } + /** + * Move the element by a relative offset. + * + * @param dx Horizontal offset in workspace units. + * @param dy Vertical offset in workspace units. + * @param _reason Why is this move happening? 'user', 'bump', 'snap'... + */ + moveBy(dx: number, dy: number, _reason?: string[]) { + this.moveTo(this.position.x + dx, this.position.y + dy); + } + /** @returns Whether or not the button is a label. */ isLabel(): boolean { return this.isFlyoutLabel; @@ -250,6 +274,21 @@ export class FlyoutButton implements IASTNodeLocationSvg { return this.position; } + /** + * Returns the coordinates of a bounded element describing the dimensions of + * the element. Coordinate system: workspace coordinates. + * + * @returns Object with coordinates of the bounded element. + */ + getBoundingRectangle() { + return new Rect( + this.position.y, + this.position.y + this.height, + this.position.x, + this.position.x + this.width, + ); + } + /** @returns Text of the button. */ getButtonText(): string { return this.text; @@ -275,9 +314,8 @@ export class FlyoutButton implements IASTNodeLocationSvg { /** Dispose of this button. */ dispose() { - if (this.onMouseUpWrapper) { - browserEvents.unbind(this.onMouseUpWrapper); - } + browserEvents.unbind(this.onMouseDownWrapper); + browserEvents.unbind(this.onMouseUpWrapper); if (this.svgGroup) { dom.removeNode(this.svgGroup); } @@ -303,15 +341,6 @@ export class FlyoutButton implements IASTNodeLocationSvg { } } - /** - * Required by IASTNodeLocationSvg, but not used. A marker cannot be set on a - * button. If the 'mark' shortcut is used on a button, its associated callback - * function is triggered. - */ - setMarkerSvg() { - throw new Error('Attempted to set a marker on a button.'); - } - /** * Do something when the button is clicked. * @@ -342,6 +371,42 @@ export class FlyoutButton implements IASTNodeLocationSvg { } } } + + private onMouseDown(e: PointerEvent) { + const gesture = this.targetWorkspace.getGesture(e); + const flyout = this.targetWorkspace.getFlyout(); + if (gesture && flyout) { + gesture.handleFlyoutStart(e, flyout); + } + } + + /** + * @returns The root SVG element of this rendered element. + */ + getSvgRoot() { + return this.svgGroup; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.svgGroup; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.workspace; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } } /** CSS for buttons and labels. See css.js for use. */ diff --git a/core/flyout_horizontal.ts b/core/flyout_horizontal.ts index 6e77636e86b..47b7ab06abd 100644 --- a/core/flyout_horizontal.ts +++ b/core/flyout_horizontal.ts @@ -13,8 +13,8 @@ import * as browserEvents from './browser_events.js'; import * as dropDownDiv from './dropdowndiv.js'; -import {Flyout, FlyoutItem} from './flyout_base.js'; -import type {FlyoutButton} from './flyout_button.js'; +import {Flyout} from './flyout_base.js'; +import type {FlyoutItem} from './flyout_item.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import {Scrollbar} from './scrollbar.js'; @@ -98,7 +98,7 @@ export class HorizontalFlyout extends Flyout { if (atTop) { y = toolboxMetrics.height; } else { - y = viewMetrics.height - this.height_; + y = viewMetrics.height - this.getHeight(); } } else { if (atTop) { @@ -116,7 +116,7 @@ export class HorizontalFlyout extends Flyout { // to align the bottom edge of the flyout with the bottom edge of the // blocklyDiv, we calculate the full height of the div minus the height // of the flyout. - y = viewMetrics.height + absoluteMetrics.top - this.height_; + y = viewMetrics.height + absoluteMetrics.top - this.getHeight(); } } @@ -133,13 +133,13 @@ export class HorizontalFlyout extends Flyout { this.width_ = targetWorkspaceViewMetrics.width; const edgeWidth = targetWorkspaceViewMetrics.width - 2 * this.CORNER_RADIUS; - const edgeHeight = this.height_ - this.CORNER_RADIUS; + const edgeHeight = this.getHeight() - this.CORNER_RADIUS; this.setBackgroundPath(edgeWidth, edgeHeight); const x = this.getX(); const y = this.getY(); - this.positionAt_(this.width_, this.height_, x, y); + this.positionAt_(this.getWidth(), this.getHeight(), x, y); } /** @@ -252,10 +252,9 @@ export class HorizontalFlyout extends Flyout { /** * Lay out the blocks in the flyout. * - * @param contents The blocks and buttons to lay out. - * @param gaps The visible gaps between blocks. + * @param contents The flyout items to lay out. */ - protected override layout_(contents: FlyoutItem[], gaps: number[]) { + protected override layout_(contents: FlyoutItem[]) { this.workspace_.scale = this.targetWorkspace!.scale; const margin = this.MARGIN; let cursorX = margin + this.tabWidth_; @@ -264,43 +263,11 @@ export class HorizontalFlyout extends Flyout { contents = contents.reverse(); } - for (let i = 0, item; (item = contents[i]); i++) { - if (item.type === 'block') { - const block = item.block; - - if (block === undefined || block === null) { - continue; - } - - const allBlocks = block.getDescendants(false); - - for (let j = 0, child; (child = allBlocks[j]); j++) { - // Mark blocks as being inside a flyout. This is used to detect and - // prevent the closure of the flyout if the user right-clicks on such - // a block. - child.isInFlyout = true; - } - const root = block.getSvgRoot(); - const blockHW = block.getHeightWidth(); - // Figure out where to place the block. - const tab = block.outputConnection ? this.tabWidth_ : 0; - let moveX; - if (this.RTL) { - moveX = cursorX + blockHW.width; - } else { - moveX = cursorX - tab; - } - block.moveBy(moveX, cursorY); - - const rect = this.createRect_(block, moveX, cursorY, blockHW, i); - cursorX += blockHW.width + gaps[i]; - - this.addBlockListeners_(root, block, rect); - } else if (item.type === 'button') { - const button = item.button as FlyoutButton; - this.initFlyoutButton_(button, cursorX, cursorY); - cursorX += button.width + gaps[i]; - } + for (const item of contents) { + const rect = item.getElement().getBoundingRectangle(); + const moveX = this.RTL ? cursorX + rect.getWidth() : cursorX; + item.getElement().moveBy(moveX, cursorY); + cursorX += item.getElement().getBoundingRectangle().getWidth(); } } @@ -367,26 +334,17 @@ export class HorizontalFlyout extends Flyout { */ protected override reflowInternal_() { this.workspace_.scale = this.getFlyoutScale(); - let flyoutHeight = 0; - const blocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = blocks[i]); i++) { - flyoutHeight = Math.max(flyoutHeight, block.getHeightWidth().height); - } - const buttons = this.buttons_; - for (let i = 0, button; (button = buttons[i]); i++) { - flyoutHeight = Math.max(flyoutHeight, button.height); - } + let flyoutHeight = this.getContents().reduce((maxHeightSoFar, item) => { + return Math.max( + maxHeightSoFar, + item.getElement().getBoundingRectangle().getHeight(), + ); + }, 0); flyoutHeight += this.MARGIN * 1.5; flyoutHeight *= this.workspace_.scale; flyoutHeight += Scrollbar.scrollbarThickness; - if (this.height_ !== flyoutHeight) { - for (let i = 0, block; (block = blocks[i]); i++) { - if (this.rectMap_.has(block)) { - this.moveRectToBlock_(this.rectMap_.get(block)!, block); - } - } - + if (this.getHeight() !== flyoutHeight) { // TODO(#7689): Remove this. // Workspace with no scrollbars where this is permanently open on the top. // If scrollbars exist they properly update the metrics. diff --git a/core/flyout_item.ts b/core/flyout_item.ts new file mode 100644 index 00000000000..26be0ed12e2 --- /dev/null +++ b/core/flyout_item.ts @@ -0,0 +1,33 @@ +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; + +/** + * Representation of an item displayed in a flyout. + */ +export class FlyoutItem { + /** + * Creates a new FlyoutItem. + * + * @param element The element that will be displayed in the flyout. + * @param type The type of element. Should correspond to the type of the + * flyout inflater that created this object. + */ + constructor( + private element: IBoundedElement & IFocusableNode, + private type: string, + ) {} + + /** + * Returns the element displayed in the flyout. + */ + getElement() { + return this.element; + } + + /** + * Returns the type of flyout element this item represents. + */ + getType() { + return this.type; + } +} diff --git a/core/flyout_navigator.ts b/core/flyout_navigator.ts new file mode 100644 index 00000000000..a102ce81765 --- /dev/null +++ b/core/flyout_navigator.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFlyout} from './interfaces/i_flyout.js'; +import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js'; +import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js'; +import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js'; +import {Navigator} from './navigator.js'; + +export class FlyoutNavigator extends Navigator { + constructor(flyout: IFlyout) { + super(); + this.rules.push( + new FlyoutButtonNavigationPolicy(), + new FlyoutSeparatorNavigationPolicy(), + ); + this.rules = this.rules.map( + (rule) => new FlyoutNavigationPolicy(rule, flyout), + ); + } +} diff --git a/core/flyout_separator.ts b/core/flyout_separator.ts new file mode 100644 index 00000000000..e9ace428ec9 --- /dev/null +++ b/core/flyout_separator.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {Rect} from './utils/rect.js'; + +/** + * Representation of a gap between elements in a flyout. + */ +export class FlyoutSeparator implements IBoundedElement, IFocusableNode { + private x = 0; + private y = 0; + + /** + * Creates a new separator. + * + * @param gap The amount of space this separator should occupy. + * @param axis The axis along which this separator occupies space. + */ + constructor( + private gap: number, + private axis: SeparatorAxis, + ) {} + + /** + * Returns the bounding box of this separator. + * + * @returns The bounding box of this separator. + */ + getBoundingRectangle(): Rect { + switch (this.axis) { + case SeparatorAxis.X: + return new Rect(this.y, this.y, this.x, this.x + this.gap); + case SeparatorAxis.Y: + return new Rect(this.y, this.y + this.gap, this.x, this.x); + } + } + + /** + * Repositions this separator. + * + * @param dx The distance to move this separator on the X axis. + * @param dy The distance to move this separator on the Y axis. + * @param _reason The reason this move was initiated. + */ + moveBy(dx: number, dy: number, _reason?: string[]) { + this.x += dx; + this.y += dy; + } + + /** + * Returns false to prevent this separator from being navigated to by the + * keyboard. + * + * @returns False. + */ + isNavigable() { + return false; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + throw new Error('Cannot be focused'); + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + throw new Error('Cannot be focused'); + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return false; + } +} + +/** + * Representation of an axis along which a separator occupies space. + */ +export const enum SeparatorAxis { + X = 'x', + Y = 'y', +} diff --git a/core/flyout_vertical.ts b/core/flyout_vertical.ts index 59682a390d2..968b7c02458 100644 --- a/core/flyout_vertical.ts +++ b/core/flyout_vertical.ts @@ -13,8 +13,8 @@ import * as browserEvents from './browser_events.js'; import * as dropDownDiv from './dropdowndiv.js'; -import {Flyout, FlyoutItem} from './flyout_base.js'; -import type {FlyoutButton} from './flyout_button.js'; +import {Flyout} from './flyout_base.js'; +import type {FlyoutItem} from './flyout_item.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import {Scrollbar} from './scrollbar.js'; @@ -86,7 +86,7 @@ export class VerticalFlyout extends Flyout { if (this.toolboxPosition_ === toolbox.Position.LEFT) { x = toolboxMetrics.width; } else { - x = viewMetrics.width - this.width_; + x = viewMetrics.width - this.getWidth(); } } else { if (this.toolboxPosition_ === toolbox.Position.LEFT) { @@ -104,7 +104,7 @@ export class VerticalFlyout extends Flyout { // to align the right edge of the flyout with the right edge of the // blocklyDiv, we calculate the full width of the div minus the width // of the flyout. - x = viewMetrics.width + absoluteMetrics.left - this.width_; + x = viewMetrics.width + absoluteMetrics.left - this.getWidth(); } } @@ -130,7 +130,7 @@ export class VerticalFlyout extends Flyout { const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); this.height_ = targetWorkspaceViewMetrics.height; - const edgeWidth = this.width_ - this.CORNER_RADIUS; + const edgeWidth = this.getWidth() - this.CORNER_RADIUS; const edgeHeight = targetWorkspaceViewMetrics.height - 2 * this.CORNER_RADIUS; this.setBackgroundPath(edgeWidth, edgeHeight); @@ -138,7 +138,7 @@ export class VerticalFlyout extends Flyout { const x = this.getX(); const y = this.getY(); - this.positionAt_(this.width_, this.height_, x, y); + this.positionAt_(this.getWidth(), this.getHeight(), x, y); } /** @@ -221,51 +221,17 @@ export class VerticalFlyout extends Flyout { /** * Lay out the blocks in the flyout. * - * @param contents The blocks and buttons to lay out. - * @param gaps The visible gaps between blocks. + * @param contents The flyout items to lay out. */ - protected override layout_(contents: FlyoutItem[], gaps: number[]) { + protected override layout_(contents: FlyoutItem[]) { this.workspace_.scale = this.targetWorkspace!.scale; const margin = this.MARGIN; const cursorX = this.RTL ? margin : margin + this.tabWidth_; let cursorY = margin; - for (let i = 0, item; (item = contents[i]); i++) { - if (item.type === 'block') { - const block = item.block; - if (!block) { - continue; - } - const allBlocks = block.getDescendants(false); - for (let j = 0, child; (child = allBlocks[j]); j++) { - // Mark blocks as being inside a flyout. This is used to detect and - // prevent the closure of the flyout if the user right-clicks on such - // a block. - child.isInFlyout = true; - } - const root = block.getSvgRoot(); - const blockHW = block.getHeightWidth(); - const moveX = block.outputConnection - ? cursorX - this.tabWidth_ - : cursorX; - block.moveBy(moveX, cursorY); - - const rect = this.createRect_( - block, - this.RTL ? moveX - blockHW.width : moveX, - cursorY, - blockHW, - i, - ); - - this.addBlockListeners_(root, block, rect); - - cursorY += blockHW.height + gaps[i]; - } else if (item.type === 'button') { - const button = item.button as FlyoutButton; - this.initFlyoutButton_(button, cursorX, cursorY); - cursorY += button.height + gaps[i]; - } + for (const item of contents) { + item.getElement().moveBy(cursorX, cursorY); + cursorY += item.getElement().getBoundingRectangle().getHeight(); } } @@ -328,52 +294,32 @@ export class VerticalFlyout extends Flyout { } /** - * Compute width of flyout. toolbox.Position mat under each block. + * Compute width of flyout. * For RTL: Lay out the blocks and buttons to be right-aligned. */ protected override reflowInternal_() { this.workspace_.scale = this.getFlyoutScale(); - let flyoutWidth = 0; - const blocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = blocks[i]); i++) { - let width = block.getHeightWidth().width; - if (block.outputConnection) { - width -= this.tabWidth_; - } - flyoutWidth = Math.max(flyoutWidth, width); - } - for (let i = 0, button; (button = this.buttons_[i]); i++) { - flyoutWidth = Math.max(flyoutWidth, button.width); - } + let flyoutWidth = this.getContents().reduce((maxWidthSoFar, item) => { + return Math.max( + maxWidthSoFar, + item.getElement().getBoundingRectangle().getWidth(), + ); + }, 0); flyoutWidth += this.MARGIN * 1.5 + this.tabWidth_; flyoutWidth *= this.workspace_.scale; flyoutWidth += Scrollbar.scrollbarThickness; - if (this.width_ !== flyoutWidth) { - for (let i = 0, block; (block = blocks[i]); i++) { - if (this.RTL) { - // With the flyoutWidth known, right-align the blocks. - const oldX = block.getRelativeToSurfaceXY().x; - let newX = flyoutWidth / this.workspace_.scale - this.MARGIN; - if (!block.outputConnection) { - newX -= this.tabWidth_; - } - block.moveBy(newX - oldX, 0); - } - if (this.rectMap_.has(block)) { - this.moveRectToBlock_(this.rectMap_.get(block)!, block); - } - } + if (this.getWidth() !== flyoutWidth) { if (this.RTL) { - // With the flyoutWidth known, right-align the buttons. - for (let i = 0, button; (button = this.buttons_[i]); i++) { - const y = button.getPosition().y; - const x = + // With the flyoutWidth known, right-align the flyout contents. + for (const item of this.getContents()) { + const oldX = item.getElement().getBoundingRectangle().left; + const newX = flyoutWidth / this.workspace_.scale - - button.width - + item.getElement().getBoundingRectangle().getWidth() - this.MARGIN - this.tabWidth_; - button.moveTo(x, y); + item.getElement().moveBy(newX - oldX, 0); } } diff --git a/core/focus_manager.ts b/core/focus_manager.ts new file mode 100644 index 00000000000..c0139aec08d --- /dev/null +++ b/core/focus_manager.ts @@ -0,0 +1,520 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import * as dom from './utils/dom.js'; +import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; + +/** + * Type declaration for returning focus to FocusManager upon completing an + * ephemeral UI flow (such as a dialog). + * + * See FocusManager.takeEphemeralFocus for more details. + */ +export type ReturnEphemeralFocus = () => void; + +/** + * A per-page singleton that manages Blockly focus across one or more + * IFocusableTrees, and bidirectionally synchronizes this focus with the DOM. + * + * Callers that wish to explicitly change input focus for select Blockly + * components on the page should use the focus functions in this manager. + * + * The manager is responsible for handling focus events from the DOM (which may + * may arise from users clicking on page elements) and ensuring that + * corresponding IFocusableNodes are clearly marked as actively/passively + * highlighted in the same way that this would be represented with calls to + * focusNode(). + */ +export class FocusManager { + /** + * The CSS class assigned to IFocusableNode elements that presently have + * active DOM and Blockly focus. + * + * This should never be used directly. Instead, rely on FocusManager to ensure + * nodes have active focus (either automatically through DOM focus or manually + * through the various focus* methods provided by this class). + * + * It's recommended to not query using this class name, either. Instead, use + * FocusableTreeTraverser or IFocusableTree's methods to find a specific node. + */ + static readonly ACTIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyActiveFocus'; + + /** + * The CSS class assigned to IFocusableNode elements that presently have + * passive focus (that is, they were the most recent node in their relative + * tree to have active focus--see ACTIVE_FOCUS_NODE_CSS_CLASS_NAME--and will + * receive active focus again if their surrounding tree is requested to become + * focused, i.e. using focusTree below). + * + * See ACTIVE_FOCUS_NODE_CSS_CLASS_NAME for caveats and limitations around + * using this constant directly (generally it never should need to be used). + */ + static readonly PASSIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyPassiveFocus'; + + private focusedNode: IFocusableNode | null = null; + private previouslyFocusedNode: IFocusableNode | null = null; + private registeredTrees: Array = []; + + private currentlyHoldsEphemeralFocus: boolean = false; + private lockFocusStateChanges: boolean = false; + private recentlyLostAllFocus: boolean = false; + + constructor( + addGlobalEventListener: (type: string, listener: EventListener) => void, + ) { + // Note that 'element' here is the element *gaining* focus. + const maybeFocus = (element: Element | EventTarget | null) => { + this.recentlyLostAllFocus = !element; + let newNode: IFocusableNode | null | undefined = null; + if (element instanceof HTMLElement || element instanceof SVGElement) { + // If the target losing or gaining focus maps to any tree, then it + // should be updated. Per the contract of findFocusableNodeFor only one + // tree should claim the element, so the search can be exited early. + for (const tree of this.registeredTrees) { + newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree); + if (newNode) break; + } + } + + if (newNode && newNode.canBeFocused()) { + const newTree = newNode.getFocusableTree(); + const oldTree = this.focusedNode?.getFocusableTree(); + if (newNode === newTree.getRootFocusableNode() && newTree !== oldTree) { + // If the root of the tree is the one taking focus (such as due to + // being tabbed), try to focus the whole tree explicitly to ensure the + // correct node re-receives focus. + this.focusTree(newTree); + } else { + this.focusNode(newNode); + } + } else { + this.defocusCurrentFocusedNode(); + } + }; + + // Register root document focus listeners for tracking when focus leaves all + // tracked focusable trees. Note that focusin and focusout can be somewhat + // overlapping in the information that they provide. This is fine because + // they both aim to check for focus changes on the element gaining or having + // received focus, and maybeFocus should behave relatively deterministic. + addGlobalEventListener('focusin', (event) => { + if (!(event instanceof FocusEvent)) return; + + // When something receives focus, always use the current active element as + // the source of truth. + maybeFocus(document.activeElement); + }); + addGlobalEventListener('focusout', (event) => { + if (!(event instanceof FocusEvent)) return; + + // When something loses focus, it seems that document.activeElement may + // not necessarily be correct. Instead, use relatedTarget. + maybeFocus(event.relatedTarget); + }); + } + + /** + * Registers a new IFocusableTree for automatic focus management. + * + * If the tree currently has an element with DOM focus, it will not affect the + * internal state in this manager until the focus changes to a new, + * now-monitored element/node. + * + * This function throws if the provided tree is already currently registered + * in this manager. Use isRegistered to check in cases when it can't be + * certain whether the tree has been registered. + */ + registerTree(tree: IFocusableTree): void { + this.ensureManagerIsUnlocked(); + if (this.isRegistered(tree)) { + throw Error(`Attempted to re-register already registered tree: ${tree}.`); + } + this.registeredTrees.push(tree); + } + + /** + * Returns whether the specified tree has already been registered in this + * manager using registerTree and hasn't yet been unregistered using + * unregisterTree. + */ + isRegistered(tree: IFocusableTree): boolean { + return this.registeredTrees.findIndex((reg) => reg === tree) !== -1; + } + + /** + * Unregisters a IFocusableTree from automatic focus management. + * + * If the tree had a previous focused node, it will have its highlight + * removed. This function does NOT change DOM focus. + * + * This function throws if the provided tree is not currently registered in + * this manager. + */ + unregisterTree(tree: IFocusableTree): void { + this.ensureManagerIsUnlocked(); + if (!this.isRegistered(tree)) { + throw Error(`Attempted to unregister not registered tree: ${tree}.`); + } + const treeIndex = this.registeredTrees.findIndex((reg) => reg === tree); + this.registeredTrees.splice(treeIndex, 1); + + const focusedNode = FocusableTreeTraverser.findFocusedNode(tree); + const root = tree.getRootFocusableNode(); + if (focusedNode) this.removeHighlight(focusedNode); + if (this.focusedNode === focusedNode || this.focusedNode === root) { + this.updateFocusedNode(null); + } + this.removeHighlight(root); + } + + /** + * Returns the current IFocusableTree that has focus, or null if none + * currently do. + * + * Note also that if ephemeral focus is currently captured (e.g. using + * takeEphemeralFocus) then the returned tree here may not currently have DOM + * focus. + */ + getFocusedTree(): IFocusableTree | null { + return this.focusedNode?.getFocusableTree() ?? null; + } + + /** + * Returns the current IFocusableNode with focus (which is always tied to a + * focused IFocusableTree), or null if there isn't one. + * + * Note that this function will maintain parity with + * IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but + * none of its non-root children do, this will return null but + * getFocusedTree() will not. + * + * Note also that if ephemeral focus is currently captured (e.g. using + * takeEphemeralFocus) then the returned node here may not currently have DOM + * focus. + */ + getFocusedNode(): IFocusableNode | null { + return this.focusedNode; + } + + /** + * Focuses the specific IFocusableTree. This either means restoring active + * focus to the tree's passively focused node, or focusing the tree's root + * node. + * + * Note that if the specified tree already has a focused node then this will + * not change any existing focus (unless that node has passive focus, then it + * will be restored to active focus). + * + * See getFocusedNode for details on how other nodes are affected. + * + * @param focusableTree The tree that should receive active + * focus. + */ + focusTree(focusableTree: IFocusableTree): void { + this.ensureManagerIsUnlocked(); + if (!this.isRegistered(focusableTree)) { + throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`); + } + const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree); + const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode); + const rootFallback = focusableTree.getRootFocusableNode(); + this.focusNode(nodeToRestore ?? currNode ?? rootFallback); + } + + /** + * Focuses DOM input on the specified node, and marks it as actively focused. + * + * Any previously focused node will be updated to be passively highlighted (if + * it's in a different focusable tree) or blurred (if it's in the same one). + * + * **Important**: If the provided node is not able to be focused (e.g. its + * canBeFocused() method returns false), it will be ignored and any existing + * focus state will remain unchanged. + * + * @param focusableNode The node that should receive active focus. + */ + focusNode(focusableNode: IFocusableNode): void { + this.ensureManagerIsUnlocked(); + if (this.focusedNode === focusableNode) return; // State is unchanged. + if (!focusableNode.canBeFocused()) { + // This node can't be focused. + console.warn("Trying to focus a node that can't be focused."); + return; + } + + const nextTree = focusableNode.getFocusableTree(); + if (!this.isRegistered(nextTree)) { + throw Error(`Attempted to focus unregistered node: ${focusableNode}.`); + } + + // Safety check for ensuring focusNode() doesn't get called for a node that + // isn't actually hooked up to its parent tree correctly. This usually + // happens when calls to focusNode() interleave with asynchronous clean-up + // operations (which can happen due to ephemeral focus and in other cases). + // Fall back to a reasonable default since there's no valid node to focus. + const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( + focusableNode.getFocusableElement(), + nextTree, + ); + const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree); + let nodeToFocus = focusableNode; + if (matchedNode !== focusableNode) { + const nodeToRestore = nextTree.getRestoredFocusableNode(prevNodeNextTree); + const rootFallback = nextTree.getRootFocusableNode(); + nodeToFocus = nodeToRestore ?? prevNodeNextTree ?? rootFallback; + } + + const prevNode = this.focusedNode; + const prevTree = prevNode?.getFocusableTree(); + if (prevNode) { + this.passivelyFocusNode(prevNode, nextTree); + } + + // If there's a focused node in the new node's tree, ensure it's reset. + const nextTreeRoot = nextTree.getRootFocusableNode(); + if (prevNodeNextTree) { + this.removeHighlight(prevNodeNextTree); + } + // For caution, ensure that the root is always reset since getFocusedNode() + // is expected to return null if the root was highlighted, if the root is + // not the node now being set to active. + if (nextTreeRoot !== nodeToFocus) { + this.removeHighlight(nextTreeRoot); + } + + if (!this.currentlyHoldsEphemeralFocus) { + // Only change the actively focused node if ephemeral state isn't held. + this.activelyFocusNode(nodeToFocus, prevTree ?? null); + } + this.updateFocusedNode(nodeToFocus); + } + + /** + * Ephemerally captures focus for a specific element until the returned lambda + * is called. This is expected to be especially useful for ephemeral UI flows + * like dialogs. + * + * IMPORTANT: the returned lambda *must* be called, otherwise automatic focus + * will no longer work anywhere on the page. It is highly recommended to tie + * the lambda call to the closure of the corresponding UI so that if input is + * manually changed to an element outside of the ephemeral UI, the UI should + * close and automatic input restored. Note that this lambda must be called + * exactly once and that subsequent calls will throw an error. + * + * Note that the manager will continue to track DOM input signals even when + * ephemeral focus is active, but it won't actually change node state until + * the returned lambda is called. Additionally, only 1 ephemeral focus context + * can be active at any given time (attempting to activate more than one + * simultaneously will result in an error being thrown). + */ + takeEphemeralFocus( + focusableElement: HTMLElement | SVGElement, + ): ReturnEphemeralFocus { + this.ensureManagerIsUnlocked(); + if (this.currentlyHoldsEphemeralFocus) { + throw Error( + `Attempted to take ephemeral focus when it's already held, ` + + `with new element: ${focusableElement}.`, + ); + } + this.currentlyHoldsEphemeralFocus = true; + + if (this.focusedNode) { + this.passivelyFocusNode(this.focusedNode, null); + } + focusableElement.focus(); + + let hasFinishedEphemeralFocus = false; + return () => { + if (hasFinishedEphemeralFocus) { + throw Error( + `Attempted to finish ephemeral focus twice for element: ` + + `${focusableElement}.`, + ); + } + hasFinishedEphemeralFocus = true; + this.currentlyHoldsEphemeralFocus = false; + + if (this.focusedNode) { + this.activelyFocusNode(this.focusedNode, null); + + // Even though focus was restored, check if it's lost again. It's + // possible for the browser to force focus away from all elements once + // the ephemeral element disappears. This ensures focus is restored. + const capturedNode = this.focusedNode; + setTimeout(() => { + // These checks are set up to minimize the risk that a legitimate + // focus change occurred within the delay that this would override. + if ( + !this.focusedNode && + this.previouslyFocusedNode === capturedNode && + this.recentlyLostAllFocus + ) { + this.focusNode(capturedNode); + } + }, 0); + } + }; + } + + /** + * Ensures that the manager is currently allowing operations that change its + * internal focus state (such as via focusNode()). + * + * If the manager is currently not allowing state changes, an exception is + * thrown. + */ + private ensureManagerIsUnlocked(): void { + if (this.lockFocusStateChanges) { + throw Error( + 'FocusManager state changes cannot happen in a tree/node focus/blur ' + + 'callback.', + ); + } + } + + /** + * Updates the internally tracked focused node to the specified node, or null + * if focus is being lost. This also updates previous focus tracking. + * + * @param newFocusedNode The new node to set as focused. + */ + private updateFocusedNode(newFocusedNode: IFocusableNode | null) { + this.previouslyFocusedNode = this.focusedNode; + this.focusedNode = newFocusedNode; + } + + /** + * Defocuses the current actively focused node tracked by the manager, iff + * there's a node being tracked and the manager doesn't have ephemeral focus. + */ + private defocusCurrentFocusedNode(): void { + // The current node will likely be defocused while ephemeral focus is held, + // but internal manager state shouldn't change since the node should be + // restored upon exiting ephemeral focus mode. + if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { + this.passivelyFocusNode(this.focusedNode, null); + this.updateFocusedNode(null); + } + } + + /** + * Marks the specified node as actively focused, also calling related + * lifecycle callback methods for both the node and its parent tree. This + * ensures that the node is properly styled to indicate its active focus. + * + * This does not change the manager's currently tracked node, nor does it + * change any other nodes. + * + * @param node The node to be actively focused. + * @param prevTree The tree of the previously actively focused node, or null + * if there wasn't a previously actively focused node. + */ + private activelyFocusNode( + node: IFocusableNode, + prevTree: IFocusableTree | null, + ): void { + // Note that order matters here. Focus callbacks are allowed to change + // element visibility which can influence focusability, including for a + // node's focusable element (which *is* allowed to be invisible until the + // node needs to be focused). + this.lockFocusStateChanges = true; + if (node.getFocusableTree() !== prevTree) { + node.getFocusableTree().onTreeFocus(node, prevTree); + } + node.onNodeFocus(); + this.lockFocusStateChanges = false; + + this.setNodeToVisualActiveFocus(node); + node.getFocusableElement().focus(); + } + + /** + * Marks the specified node as passively focused, also calling related + * lifecycle callback methods for both the node and its parent tree. This + * ensures that the node is properly styled to indicate its passive focus. + * + * This does not change the manager's currently tracked node, nor does it + * change any other nodes. + * + * @param node The node to be passively focused. + * @param nextTree The tree of the node receiving active focus, or null if no + * node will be actively focused. + */ + private passivelyFocusNode( + node: IFocusableNode, + nextTree: IFocusableTree | null, + ): void { + this.lockFocusStateChanges = true; + if (node.getFocusableTree() !== nextTree) { + node.getFocusableTree().onTreeBlur(nextTree); + } + node.onNodeBlur(); + this.lockFocusStateChanges = false; + + if (node.getFocusableTree() !== nextTree) { + this.setNodeToVisualPassiveFocus(node); + } + } + + /** + * Updates the node's styling to indicate that it should have an active focus + * indicator. + * + * @param node The node to be styled for active focus. + */ + private setNodeToVisualActiveFocus(node: IFocusableNode): void { + const element = node.getFocusableElement(); + dom.addClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + /** + * Updates the node's styling to indicate that it should have a passive focus + * indicator. + * + * @param node The node to be styled for passive focus. + */ + private setNodeToVisualPassiveFocus(node: IFocusableNode): void { + const element = node.getFocusableElement(); + dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + dom.addClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + /** + * Removes any active/passive indicators for the specified node. + * + * @param node The node which should have neither passive nor active focus + * indication. + */ + private removeHighlight(node: IFocusableNode): void { + const element = node.getFocusableElement(); + dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + private static focusManager: FocusManager | null = null; + + /** + * Returns the page-global FocusManager. + * + * The returned instance is guaranteed to not change across function calls, + * but may change across page loads. + */ + static getFocusManager(): FocusManager { + if (!FocusManager.focusManager) { + FocusManager.focusManager = new FocusManager(document.addEventListener); + } + return FocusManager.focusManager; + } +} + +/** Convenience function for FocusManager.getFocusManager. */ +export function getFocusManager(): FocusManager { + return FocusManager.getFocusManager(); +} diff --git a/core/generator.ts b/core/generator.ts index 5884b4e5449..24510fd5b3a 100644 --- a/core/generator.ts +++ b/core/generator.ts @@ -252,8 +252,7 @@ export class CodeGenerator { return opt_thisOnly ? '' : this.blockToCode(block.getChildren(false)[0]); } - // Look up block generator function in dictionary - but fall back - // to looking up on this if not found, for backwards compatibility. + // Look up block generator function in dictionary. const func = this.forBlock[block.type]; if (typeof func !== 'function') { throw Error( diff --git a/core/gesture.ts b/core/gesture.ts index 0b65299e578..f9b435c67d9 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -25,6 +25,7 @@ import * as dropDownDiv from './dropdowndiv.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import type {Field} from './field.js'; +import {getFocusManager} from './focus_manager.js'; import type {IBubble} from './interfaces/i_bubble.js'; import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; import {IDragger} from './interfaces/i_dragger.js'; @@ -289,7 +290,7 @@ export class Gesture { // The start block is no longer relevant, because this is a drag. this.startBlock = null; this.targetBlock = this.flyout.createBlock(this.targetBlock); - common.setSelected(this.targetBlock); + getFocusManager().focusNode(this.targetBlock); return true; } return false; @@ -734,6 +735,7 @@ export class Gesture { this.startComment.showContextMenu(e); } else if (this.startWorkspace_ && !this.flyout) { this.startWorkspace_.hideChaff(); + getFocusManager().focusNode(this.startWorkspace_); this.startWorkspace_.showContextMenu(e); } @@ -762,9 +764,12 @@ export class Gesture { this.mostRecentEvent = e; if (!this.startBlock && !this.startBubble && !this.startComment) { - // Selection determines what things start drags. So to drag the workspace, - // we need to deselect anything that was previously selected. - common.setSelected(null); + // Ensure the workspace is selected if nothing else should be. Note that + // this is focusNode() instead of focusTree() because if any active node + // is focused in the workspace it should be defocused. + getFocusManager().focusNode(ws); + } else if (this.startBlock) { + getFocusManager().focusNode(this.startBlock); } this.doStart(e); @@ -865,13 +870,18 @@ export class Gesture { ); } + // Note that the order is important here: bringing a block to the front will + // cause it to become focused and showing the field editor will capture + // focus ephemerally. It's important to ensure that focus is properly + // restored back to the block after field editing has completed. + this.bringBlockToFront(); + // Only show the editor if the field's editor wasn't already open // right before this gesture started. const dropdownAlreadyOpen = this.currentDropdownOwner === this.startField; if (!dropdownAlreadyOpen) { this.startField.showEditor(this.mostRecentEvent); } - this.bringBlockToFront(); } /** Execute an icon click. */ @@ -894,13 +904,16 @@ export class Gesture { 'Cannot do a block click because the target block is ' + 'undefined', ); } - if (this.targetBlock.isEnabled()) { + if (this.flyout.isBlockCreatable(this.targetBlock)) { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } const newBlock = this.flyout.createBlock(this.targetBlock); newBlock.snapToGrid(); newBlock.bumpNeighbours(); + + // If a new block was added, make sure that it's correctly focused. + getFocusManager().focusNode(newBlock); } } else { if (!this.startWorkspace_) { @@ -928,11 +941,7 @@ export class Gesture { * @param _e A pointerup event. */ private doWorkspaceClick(_e: PointerEvent) { - const ws = this.creatorWorkspace; - if (common.getSelected()) { - common.getSelected()!.unselect(); - } - this.fireWorkspaceClick(this.startWorkspace_ || ws); + this.fireWorkspaceClick(this.startWorkspace_ || this.creatorWorkspace); } /* End functions defining what actions to take to execute clicks on each type @@ -947,6 +956,8 @@ export class Gesture { private bringBlockToFront() { // Blocks in the flyout don't overlap, so skip the work. if (this.targetBlock && !this.flyout) { + // Always ensure the block being dragged/clicked has focus. + getFocusManager().focusNode(this.targetBlock); this.targetBlock.bringToFront(); } } @@ -1023,7 +1034,6 @@ export class Gesture { // If the gesture already went through a bubble, don't set the start block. if (!this.startBlock && !this.startBubble) { this.startBlock = block; - common.setSelected(this.startBlock); if (block.isInFlyout && block !== block.getRootBlock()) { this.setTargetBlock(block.getRootBlock()); } else { @@ -1046,6 +1056,7 @@ export class Gesture { this.setTargetBlock(block.getParent()!); } else { this.targetBlock = block; + getFocusManager().focusNode(block); } } diff --git a/core/grid.ts b/core/grid.ts index e2fc054a262..2d88973adc2 100644 --- a/core/grid.ts +++ b/core/grid.ts @@ -210,6 +210,9 @@ export class Grid { * @param rnd A random ID to append to the pattern's ID. * @param gridOptions The object containing grid configuration. * @param defs The root SVG element for this workspace's defs. + * @param injectionDiv The div containing the parent workspace and all related + * workspaces and block containers. CSS variables representing SVG patterns + * will be scoped to this container. * @returns The SVG element for the grid pattern. * @internal */ @@ -217,6 +220,7 @@ export class Grid { rnd: string, gridOptions: GridOptions, defs: SVGElement, + injectionDiv?: HTMLElement, ): SVGElement { /* @@ -247,6 +251,17 @@ export class Grid { // Edge 16 doesn't handle empty patterns dom.createSvgElement(Svg.LINE, {}, gridPattern); } + + if (injectionDiv) { + // Add CSS variables scoped to the injection div referencing the created + // patterns so that CSS can apply the patterns to any element in the + // injection div. + injectionDiv.style.setProperty( + '--blocklyGridPattern', + `url(#${gridPattern.id})`, + ); + } + return gridPattern; } } diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 24a276d877f..959eb2500f5 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -11,6 +11,7 @@ import type {BlockSvg} from '../block_svg.js'; import {TextInputBubble} from '../bubbles/textinput_bubble.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import type {ISerializable} from '../interfaces/i_serializable.js'; import * as renderManagement from '../render_management.js'; @@ -55,6 +56,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { /** The size of this comment (which is applied to the editable bubble). */ private bubbleSize = new Size(DEFAULT_BUBBLE_WIDTH, DEFAULT_BUBBLE_HEIGHT); + /** The location of the comment bubble in workspace coordinates. */ + private bubbleLocation?: Coordinate; + /** * The visibility of the bubble for this comment. * @@ -108,7 +112,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { }, this.svgRoot, ); - dom.addClass(this.svgRoot!, 'blockly-icon-comment'); + dom.addClass(this.svgRoot!, 'blocklyCommentIcon'); } override dispose() { @@ -144,7 +148,13 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { } override onLocationChange(blockOrigin: Coordinate): void { + const oldLocation = this.workspaceLocation; super.onLocationChange(blockOrigin); + if (this.bubbleLocation) { + const newLocation = this.workspaceLocation; + const delta = Coordinate.difference(newLocation, oldLocation); + this.bubbleLocation = Coordinate.sum(this.bubbleLocation, delta); + } const anchorLocation = this.getAnchorLocation(); this.textInputBubble?.setAnchorLocation(anchorLocation); } @@ -184,18 +194,42 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { return this.bubbleSize; } + /** + * Sets the location of the comment bubble in the workspace. + */ + setBubbleLocation(location: Coordinate) { + this.bubbleLocation = location; + this.textInputBubble?.moveDuringDrag(location); + } + + /** + * @returns the location of the comment bubble in the workspace. + */ + getBubbleLocation(): Coordinate | undefined { + return this.bubbleLocation; + } + /** * @returns the state of the comment as a JSON serializable value if the * comment has text. Otherwise returns null. */ saveState(): CommentState | null { if (this.text) { - return { + const state: CommentState = { 'text': this.text, 'pinned': this.bubbleIsVisible(), 'height': this.bubbleSize.height, 'width': this.bubbleSize.width, }; + const location = this.getBubbleLocation(); + if (location) { + state['x'] = this.sourceBlock.workspace.RTL + ? this.sourceBlock.workspace.getWidth() - + (location.x + this.bubbleSize.width) + : location.x; + state['y'] = location.y; + } + return state; } return null; } @@ -209,6 +243,16 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { ); this.bubbleVisiblity = state['pinned'] ?? false; this.setBubbleVisible(this.bubbleVisiblity); + let x = state['x']; + const y = state['y']; + renderManagement.finishQueuedRenders().then(() => { + if (x && y) { + x = this.sourceBlock.workspace.RTL + ? this.sourceBlock.workspace.getWidth() - (x + this.bubbleSize.width) + : x; + this.setBubbleLocation(new Coordinate(x, y)); + } + }); } override onClick(): void { @@ -252,6 +296,12 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { } } + onBubbleLocationChange(): void { + if (this.textInputBubble) { + this.bubbleLocation = this.textInputBubble.getRelativeToSurfaceXY(); + } + } + bubbleIsVisible(): boolean { return this.bubbleVisiblity; } @@ -289,6 +339,11 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { ); } + /** See IHasBubble.getBubble. */ + getBubble(): IBubble | null { + return this.textInputBubble; + } + /** * Shows the editable text bubble for this comment, and adds change listeners * to update the state of this icon in response to changes in the bubble. @@ -313,6 +368,14 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { ); this.textInputBubble.setText(this.getText()); this.textInputBubble.setSize(this.bubbleSize, true); + if (this.bubbleLocation) { + this.textInputBubble.moveDuringDrag(this.bubbleLocation); + } + this.textInputBubble.addTextChangeListener(() => this.onTextChange()); + this.textInputBubble.addSizeChangeListener(() => this.onSizeChange()); + this.textInputBubble.addLocationChangeListener(() => + this.onBubbleLocationChange(), + ); } /** Hides any open bubbles owned by this comment. */ @@ -355,6 +418,12 @@ export interface CommentState { /** The width of the comment bubble. */ width?: number; + + /** The X coordinate of the comment bubble. */ + x?: number; + + /** The Y coordinate of the comment bubble. */ + y?: number; } registry.register(CommentIcon.TYPE, CommentIcon); diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 30a6b538f6e..5bf61d49c91 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -7,13 +7,16 @@ import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; import * as browserEvents from '../browser_events.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {hasBubble} from '../interfaces/i_has_bubble.js'; import type {IIcon} from '../interfaces/i_icon.js'; import * as tooltip from '../tooltip.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; +import * as idGenerator from '../utils/idgenerator.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; import type {IconType} from './icon_types.js'; /** @@ -38,8 +41,12 @@ export abstract class Icon implements IIcon { /** The tooltip for this icon. */ protected tooltip: tooltip.TipInfo; + /** The unique ID of this icon. */ + private id: string; + constructor(protected sourceBlock: Block) { this.tooltip = sourceBlock; + this.id = idGenerator.getNextUniqueId(); } getType(): IconType { @@ -50,7 +57,11 @@ export abstract class Icon implements IIcon { if (this.svgRoot) return; // The icon has already been initialized. const svgBlock = this.sourceBlock as BlockSvg; - this.svgRoot = dom.createSvgElement(Svg.G, {'class': 'blocklyIconGroup'}); + this.svgRoot = dom.createSvgElement(Svg.G, { + 'class': 'blocklyIconGroup', + 'tabindex': '-1', + 'id': this.id, + }); svgBlock.getSvgRoot().appendChild(this.svgRoot); this.updateSvgRootOffset(); browserEvents.conditionalBind( @@ -144,4 +155,27 @@ export abstract class Icon implements IIcon { isClickableInFlyout(autoClosingFlyout: boolean): boolean { return true; } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + const svgRoot = this.svgRoot; + if (!svgRoot) throw new Error('Attempting to focus uninitialized icon.'); + return svgRoot; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.sourceBlock.workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } } diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index eea533eab4a..1842855fab5 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -14,6 +14,7 @@ import {BlockChange} from '../events/events_block_change.js'; import {isBlockChange, isBlockCreate} from '../events/predicates.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; import {Coordinate} from '../utils/coordinate.js'; @@ -118,7 +119,7 @@ export class MutatorIcon extends Icon implements IHasBubble { {'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'}, this.svgRoot, ); - dom.addClass(this.svgRoot!, 'blockly-icon-mutator'); + dom.addClass(this.svgRoot!, 'blocklyMutatorIcon'); } override dispose(): void { @@ -203,6 +204,11 @@ export class MutatorIcon extends Icon implements IHasBubble { ); } + /** See IHasBubble.getBubble. */ + getBubble(): IBubble | null { + return this.miniWorkspaceBubble; + } + /** @returns the configuration the mini workspace should have. */ private getMiniWorkspaceConfig() { const options: BlocklyOptions = { diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts index b82ad10971d..f24a6a56190 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -10,6 +10,7 @@ import type {BlockSvg} from '../block_svg.js'; import {TextBubble} from '../bubbles/text_bubble.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; import {Size} from '../utils.js'; @@ -90,7 +91,7 @@ export class WarningIcon extends Icon implements IHasBubble { }, this.svgRoot, ); - dom.addClass(this.svgRoot!, 'blockly-icon-warning'); + dom.addClass(this.svgRoot!, 'blocklyWarningIcon'); } override dispose() { @@ -197,6 +198,11 @@ export class WarningIcon extends Icon implements IHasBubble { ); } + /** See IHasBubble.getBubble. */ + getBubble(): IBubble | null { + return this.textBubble; + } + /** * @returns the location the bubble should be anchored to. * I.E. the middle of this icon. diff --git a/core/inject.ts b/core/inject.ts index 40016bc23f4..34d9c1795f8 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -13,13 +13,11 @@ import * as common from './common.js'; import * as Css from './css.js'; import * as dropDownDiv from './dropdowndiv.js'; import {Grid} from './grid.js'; -import {Msg} from './msg.js'; import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import {ShortcutRegistry} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; -import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -56,8 +54,6 @@ export function inject( if (opt_options?.rtl) { dom.addClass(subContainer, 'blocklyRTL'); } - subContainer.tabIndex = 0; - aria.setState(subContainer, aria.State.LABEL, Msg['WORKSPACE_ARIA_LABEL']); containerElement!.appendChild(subContainer); const svg = createDom(subContainer, options); @@ -98,7 +94,7 @@ export function inject( * @param options Dictionary of options. * @returns Newly created SVG image. */ -function createDom(container: Element, options: Options): SVGElement { +function createDom(container: HTMLElement, options: Options): SVGElement { // Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying // out content in RTL mode. Therefore Blockly forces the use of LTR, // then manually positions content in RTL as needed. @@ -126,7 +122,6 @@ function createDom(container: Element, options: Options): SVGElement { 'xmlns:xlink': dom.XLINK_NS, 'version': '1.1', 'class': 'blocklySvg', - 'tabindex': '0', }, container, ); @@ -141,7 +136,12 @@ function createDom(container: Element, options: Options): SVGElement { // https://neil.fraser.name/news/2015/11/01/ const rnd = String(Math.random()).substring(2); - options.gridPattern = Grid.createDom(rnd, options.gridOptions, defs); + options.gridPattern = Grid.createDom( + rnd, + options.gridOptions, + defs, + container, + ); return svg; } @@ -153,7 +153,7 @@ function createDom(container: Element, options: Options): SVGElement { * @returns Newly created main workspace. */ function createMainWorkspace( - injectionDiv: Element, + injectionDiv: HTMLElement, svg: SVGElement, options: Options, ): WorkspaceSvg { diff --git a/core/inputs/input.ts b/core/inputs/input.ts index 0907bf44939..f8783aea35f 100644 --- a/core/inputs/input.ts +++ b/core/inputs/input.ts @@ -20,7 +20,7 @@ import type {Connection} from '../connection.js'; import type {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import * as fieldRegistry from '../field_registry.js'; -import type {RenderedConnection} from '../rendered_connection.js'; +import {RenderedConnection} from '../rendered_connection.js'; import {Align} from './align.js'; import {inputTypes} from './input_types.js'; @@ -181,15 +181,14 @@ export class Input { for (let y = 0, field; (field = this.fieldRow[y]); y++) { field.setVisible(visible); } - if (this.connection) { - const renderedConnection = this.connection as RenderedConnection; + if (this.connection && this.connection instanceof RenderedConnection) { // Has a connection. if (visible) { - renderList = renderedConnection.startTrackingAll(); + renderList = this.connection.startTrackingAll(); } else { - renderedConnection.stopTrackingAll(); + this.connection.stopTrackingAll(); } - const child = renderedConnection.targetBlock(); + const child = this.connection.targetBlock(); if (child) { child.getSvgRoot().style.display = visible ? 'block' : 'none'; } diff --git a/core/insertion_marker_manager.ts b/core/insertion_marker_manager.ts deleted file mode 100644 index 13d63042002..00000000000 --- a/core/insertion_marker_manager.ts +++ /dev/null @@ -1,742 +0,0 @@ -/** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Class that controls updates to connections during drags. - * - * @class - */ -// Former goog.module ID: Blockly.InsertionMarkerManager - -import * as blockAnimations from './block_animations.js'; -import type {BlockSvg} from './block_svg.js'; -import * as common from './common.js'; -import {ComponentManager} from './component_manager.js'; -import {config} from './config.js'; -import * as eventUtils from './events/utils.js'; -import type {IDeleteArea} from './interfaces/i_delete_area.js'; -import type {IDragTarget} from './interfaces/i_drag_target.js'; -import * as renderManagement from './render_management.js'; -import {finishQueuedRenders} from './render_management.js'; -import type {RenderedConnection} from './rendered_connection.js'; -import * as blocks from './serialization/blocks.js'; -import type {Coordinate} from './utils/coordinate.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; - -/** Represents a nearby valid connection. */ -interface CandidateConnection { - /** - * A nearby valid connection that is compatible with local. - * This is not on any of the blocks that are being dragged. - */ - closest: RenderedConnection; - /** - * A connection on the dragging stack that is compatible with closest. This is - * on the top block that is being dragged or the last block in the dragging - * stack. - */ - local: RenderedConnection; - radius: number; -} - -/** - * Class that controls updates to connections during drags. It is primarily - * responsible for finding the closest eligible connection and highlighting or - * unhighlighting it as needed during a drag. - * - * @deprecated v10 - Use an IConnectionPreviewer instead. - */ -export class InsertionMarkerManager { - /** - * The top block in the stack being dragged. - * Does not change during a drag. - */ - private readonly topBlock: BlockSvg; - - /** - * The workspace on which these connections are being dragged. - * Does not change during a drag. - */ - private readonly workspace: WorkspaceSvg; - - /** - * The last connection on the stack, if it's not the last connection on the - * first block. - * Set in initAvailableConnections, if at all. - */ - private lastOnStack: RenderedConnection | null = null; - - /** - * The insertion marker corresponding to the last block in the stack, if - * that's not the same as the first block in the stack. - * Set in initAvailableConnections, if at all - */ - private lastMarker: BlockSvg | null = null; - - /** - * The insertion marker that shows up between blocks to show where a block - * would go if dropped immediately. - */ - private firstMarker: BlockSvg; - - /** - * Information about the connection that would be made if the dragging block - * were released immediately. Updated on every mouse move. - */ - private activeCandidate: CandidateConnection | null = null; - - /** - * Whether the block would be deleted if it were dropped immediately. - * Updated on every mouse move. - * - * @internal - */ - public wouldDeleteBlock = false; - - /** - * Connection on the insertion marker block that corresponds to - * the active candidate's local connection on the currently dragged block. - */ - private markerConnection: RenderedConnection | null = null; - - /** The block that currently has an input being highlighted, or null. */ - private highlightedBlock: BlockSvg | null = null; - - /** The block being faded to indicate replacement, or null. */ - private fadedBlock: BlockSvg | null = null; - - /** - * The connections on the dragging blocks that are available to connect to - * other blocks. This includes all open connections on the top block, as - * well as the last connection on the block stack. - */ - private availableConnections: RenderedConnection[]; - - /** @param block The top block in the stack being dragged. */ - constructor(block: BlockSvg) { - common.setSelected(block); - this.topBlock = block; - - this.workspace = block.workspace; - - this.firstMarker = this.createMarkerBlock(this.topBlock); - - this.availableConnections = this.initAvailableConnections(); - - if (this.lastOnStack) { - this.lastMarker = this.createMarkerBlock( - this.lastOnStack.getSourceBlock(), - ); - } - } - - /** - * Sever all links from this object. - * - * @internal - */ - dispose() { - this.availableConnections.length = 0; - this.disposeInsertionMarker(this.firstMarker); - this.disposeInsertionMarker(this.lastMarker); - } - - /** - * Update the available connections for the top block. These connections can - * change if a block is unplugged and the stack is healed. - * - * @internal - */ - updateAvailableConnections() { - this.availableConnections = this.initAvailableConnections(); - } - - /** - * Return whether the block would be connected if dropped immediately, based - * on information from the most recent move event. - * - * @returns True if the block would be connected if dropped immediately. - * @internal - */ - wouldConnectBlock(): boolean { - return !!this.activeCandidate; - } - - /** - * Connect to the closest connection and render the results. - * This should be called at the end of a drag. - * - * @internal - */ - applyConnections() { - if (!this.activeCandidate) return; - eventUtils.disable(); - this.hidePreview(); - eventUtils.enable(); - const {local, closest} = this.activeCandidate; - local.connect(closest); - const inferiorConnection = local.isSuperior() ? closest : local; - const rootBlock = this.topBlock.getRootBlock(); - - finishQueuedRenders().then(() => { - blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock()); - // bringToFront is incredibly expensive. Delay until the next frame. - setTimeout(() => { - rootBlock.bringToFront(); - }, 0); - }); - } - - /** - * Update connections based on the most recent move location. - * - * @param dxy Position relative to drag start, in workspace units. - * @param dragTarget The drag target that the block is currently over. - * @internal - */ - update(dxy: Coordinate, dragTarget: IDragTarget | null) { - const newCandidate = this.getCandidate(dxy); - - this.wouldDeleteBlock = this.shouldDelete(!!newCandidate, dragTarget); - - const shouldUpdate = - this.wouldDeleteBlock || this.shouldUpdatePreviews(newCandidate, dxy); - - if (shouldUpdate) { - // Don't fire events for insertion marker creation or movement. - eventUtils.disable(); - this.maybeHidePreview(newCandidate); - this.maybeShowPreview(newCandidate); - eventUtils.enable(); - } - } - - /** - * Create an insertion marker that represents the given block. - * - * @param sourceBlock The block that the insertion marker will represent. - * @returns The insertion marker that represents the given block. - */ - private createMarkerBlock(sourceBlock: BlockSvg): BlockSvg { - eventUtils.disable(); - let result: BlockSvg; - try { - const blockJson = blocks.save(sourceBlock, { - addCoordinates: false, - addInputBlocks: false, - addNextBlocks: false, - doFullSerialization: false, - }); - - if (!blockJson) { - throw new Error( - `Failed to serialize source block. ${sourceBlock.toDevString()}`, - ); - } - - result = blocks.append(blockJson, this.workspace) as BlockSvg; - - // Turn shadow blocks that are created programmatically during - // initalization to insertion markers too. - for (const block of result.getDescendants(false)) { - block.setInsertionMarker(true); - } - - result.initSvg(); - result.getSvgRoot().setAttribute('visibility', 'hidden'); - } finally { - eventUtils.enable(); - } - - return result; - } - - /** - * Populate the list of available connections on this block stack. If the - * stack has more than one block, this function will also update lastOnStack. - * - * @returns A list of available connections. - */ - private initAvailableConnections(): RenderedConnection[] { - const available = this.topBlock.getConnections_(false); - // Also check the last connection on this stack - const lastOnStack = this.topBlock.lastConnectionInStack(true); - if (lastOnStack && lastOnStack !== this.topBlock.nextConnection) { - available.push(lastOnStack); - this.lastOnStack = lastOnStack; - } - return available; - } - - /** - * Whether the previews (insertion marker and replacement marker) should be - * updated based on the closest candidate and the current drag distance. - * - * @param newCandidate A new candidate connection that may replace the current - * best candidate. - * @param dxy Position relative to drag start, in workspace units. - * @returns Whether the preview should be updated. - */ - private shouldUpdatePreviews( - newCandidate: CandidateConnection | null, - dxy: Coordinate, - ): boolean { - // Only need to update if we were showing a preview before. - if (!newCandidate) return !!this.activeCandidate; - - // We weren't showing a preview before, but we should now. - if (!this.activeCandidate) return true; - - // We're already showing an insertion marker. - // Decide whether the new connection has higher priority. - const {local: activeLocal, closest: activeClosest} = this.activeCandidate; - if ( - activeClosest === newCandidate.closest && - activeLocal === newCandidate.local - ) { - // The connection was the same as the current connection. - return false; - } - - const xDiff = activeLocal.x + dxy.x - activeClosest.x; - const yDiff = activeLocal.y + dxy.y - activeClosest.y; - const curDistance = Math.sqrt(xDiff * xDiff + yDiff * yDiff); - // Slightly prefer the existing preview over a new preview. - return ( - newCandidate.radius < curDistance - config.currentConnectionPreference - ); - } - - /** - * Find the nearest valid connection, which may be the same as the current - * closest connection. - * - * @param dxy Position relative to drag start, in workspace units. - * @returns An object containing a local connection, a closest connection, and - * a radius. - */ - private getCandidate(dxy: Coordinate): CandidateConnection | null { - // It's possible that a block has added or removed connections during a - // drag, (e.g. in a drag/move event handler), so let's update the available - // connections. Note that this will be called on every move while dragging, - // so it might cause slowness, especially if the block stack is large. If - // so, maybe it could be made more efficient. Also note that we won't update - // the connections if we've already connected the insertion marker to a - // block. - if (!this.markerConnection || !this.markerConnection.isConnected()) { - this.updateAvailableConnections(); - } - - let radius = this.getStartRadius(); - let candidate = null; - for (let i = 0; i < this.availableConnections.length; i++) { - const myConnection = this.availableConnections[i]; - const neighbour = myConnection.closest(radius, dxy); - if (neighbour.connection) { - candidate = { - closest: neighbour.connection, - local: myConnection, - radius: neighbour.radius, - }; - radius = neighbour.radius; - } - } - return candidate; - } - - /** - * Decide the radius at which to start searching for the closest connection. - * - * @returns The radius at which to start the search for the closest - * connection. - */ - private getStartRadius(): number { - // If there is already a connection highlighted, - // increase the radius we check for making new connections. - // When a connection is highlighted, blocks move around when the - // insertion marker is created, which could cause the connection became out - // of range. By increasing radiusConnection when a connection already - // exists, we never "lose" the connection from the offset. - return this.activeCandidate - ? config.connectingSnapRadius - : config.snapRadius; - } - - /** - * Whether ending the drag would delete the block. - * - * @param newCandidate Whether there is a candidate connection that the - * block could connect to if the drag ended immediately. - * @param dragTarget The drag target that the block is currently over. - * @returns Whether dropping the block immediately would delete the block. - */ - private shouldDelete( - newCandidate: boolean, - dragTarget: IDragTarget | null, - ): boolean { - if (dragTarget) { - const componentManager = this.workspace.getComponentManager(); - const isDeleteArea = componentManager.hasCapability( - dragTarget.id, - ComponentManager.Capability.DELETE_AREA, - ); - if (isDeleteArea) { - return (dragTarget as IDeleteArea).wouldDelete(this.topBlock); - } - } - return false; - } - - /** - * Show an insertion marker or replacement highlighting during a drag, if - * needed. - * At the beginning of this function, this.activeConnection should be null. - * - * @param newCandidate A new candidate connection that may replace the current - * best candidate. - */ - private maybeShowPreview(newCandidate: CandidateConnection | null) { - if (this.wouldDeleteBlock) return; // Nope, don't add a marker. - if (!newCandidate) return; // Nothing to connect to. - - const closest = newCandidate.closest; - - // Something went wrong and we're trying to connect to an invalid - // connection. - if ( - closest === this.activeCandidate?.closest || - closest.getSourceBlock().isInsertionMarker() - ) { - console.log('Trying to connect to an insertion marker'); - return; - } - this.activeCandidate = newCandidate; - // Add an insertion marker or replacement marker. - this.showPreview(this.activeCandidate); - } - - /** - * A preview should be shown. This function figures out if it should be a - * block highlight or an insertion marker, and shows the appropriate one. - * - * @param activeCandidate The connection that will be made if the drag ends - * immediately. - */ - private showPreview(activeCandidate: CandidateConnection) { - const renderer = this.workspace.getRenderer(); - const method = renderer.getConnectionPreviewMethod( - activeCandidate.closest, - activeCandidate.local, - this.topBlock, - ); - - switch (method) { - case InsertionMarkerManager.PREVIEW_TYPE.INPUT_OUTLINE: - this.showInsertionInputOutline(activeCandidate); - break; - case InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER: - this.showInsertionMarker(activeCandidate); - break; - case InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE: - this.showReplacementFade(activeCandidate); - break; - } - - // Optionally highlight the actual connection, as a nod to previous - // behaviour. - if (renderer.shouldHighlightConnection(activeCandidate.closest)) { - activeCandidate.closest.highlight(); - } - } - - /** - * Hide an insertion marker or replacement highlighting during a drag, if - * needed. - * At the end of this function, this.activeCandidate will be null. - * - * @param newCandidate A new candidate connection that may replace the current - * best candidate. - */ - private maybeHidePreview(newCandidate: CandidateConnection | null) { - // If there's no new preview, remove the old one but don't bother deleting - // it. We might need it later, and this saves disposing of it and recreating - // it. - if (!newCandidate) { - this.hidePreview(); - } else { - if (this.activeCandidate) { - const closestChanged = - this.activeCandidate.closest !== newCandidate.closest; - const localChanged = this.activeCandidate.local !== newCandidate.local; - - // If there's a new preview and there was a preview before, and either - // connection has changed, remove the old preview. - // Also hide if we had a preview before but now we're going to delete - // instead. - if (closestChanged || localChanged || this.wouldDeleteBlock) { - this.hidePreview(); - } - } - } - - // Either way, clear out old state. - this.markerConnection = null; - this.activeCandidate = null; - } - - /** - * A preview should be hidden. Loop through all possible preview modes - * and hide everything. - */ - private hidePreview() { - const closest = this.activeCandidate?.closest; - if ( - closest && - closest.targetBlock() && - this.workspace.getRenderer().shouldHighlightConnection(closest) - ) { - closest.unhighlight(); - } - this.hideReplacementFade(); - this.hideInsertionInputOutline(); - this.hideInsertionMarker(); - } - - /** - * Shows an insertion marker connected to the appropriate blocks (based on - * manager state). - * - * @param activeCandidate The connection that will be made if the drag ends - * immediately. - */ - private showInsertionMarker(activeCandidate: CandidateConnection) { - const {local, closest} = activeCandidate; - - const isLastInStack = this.lastOnStack && local === this.lastOnStack; - let insertionMarker = isLastInStack ? this.lastMarker : this.firstMarker; - if (!insertionMarker) { - throw new Error( - 'Cannot show the insertion marker because there is no insertion ' + - 'marker block', - ); - } - let imConn; - try { - imConn = insertionMarker.getMatchingConnection( - local.getSourceBlock(), - local, - ); - } catch { - // It's possible that the number of connections on the local block has - // changed since the insertion marker was originally created. Let's - // recreate the insertion marker and try again. In theory we could - // probably recreate the marker block (e.g. in getCandidate_), which is - // called more often during the drag, but creating a block that often - // might be too slow, so we only do it if necessary. - if (isLastInStack && this.lastOnStack) { - this.disposeInsertionMarker(this.lastMarker); - this.lastMarker = this.createMarkerBlock( - this.lastOnStack.getSourceBlock(), - ); - insertionMarker = this.lastMarker; - } else { - this.disposeInsertionMarker(this.firstMarker); - this.firstMarker = this.createMarkerBlock(this.topBlock); - insertionMarker = this.firstMarker; - } - - if (!insertionMarker) { - throw new Error( - 'Cannot show the insertion marker because there is no insertion ' + - 'marker block', - ); - } - imConn = insertionMarker.getMatchingConnection( - local.getSourceBlock(), - local, - ); - } - - if (!imConn) { - throw new Error( - 'Cannot show the insertion marker because there is no ' + - 'associated connection', - ); - } - - if (imConn === this.markerConnection) { - throw new Error( - "Made it to showInsertionMarker_ even though the marker isn't " + - 'changing', - ); - } - - // Render disconnected from everything else so that we have a valid - // connection location. - insertionMarker.queueRender(); - renderManagement.triggerQueuedRenders(); - - // Connect() also renders the insertion marker. - imConn.connect(closest); - - const originalOffsetToTarget = { - x: closest.x - imConn.x, - y: closest.y - imConn.y, - }; - const originalOffsetInBlock = imConn.getOffsetInBlock().clone(); - const imConnConst = imConn; - renderManagement.finishQueuedRenders().then(() => { - // Position so that the existing block doesn't move. - insertionMarker?.positionNearConnection( - imConnConst, - originalOffsetToTarget, - originalOffsetInBlock, - ); - insertionMarker?.getSvgRoot().setAttribute('visibility', 'visible'); - }); - - this.markerConnection = imConn; - } - - /** - * Disconnects and hides the current insertion marker. Should return the - * blocks to their original state. - */ - private hideInsertionMarker() { - if (!this.markerConnection) return; - - const markerConn = this.markerConnection; - const imBlock = markerConn.getSourceBlock(); - const markerPrev = imBlock.previousConnection; - const markerOutput = imBlock.outputConnection; - - if (!markerPrev?.targetConnection && !markerOutput?.targetConnection) { - // If we are the top block, unplugging doesn't do anything. - // The marker connection may not have a target block if we are hiding - // as part of applying connections. - markerConn.targetBlock()?.unplug(false); - } else { - imBlock.unplug(true); - } - - if (markerConn.targetConnection) { - throw Error( - 'markerConnection still connected at the end of ' + - 'disconnectInsertionMarker', - ); - } - - this.markerConnection = null; - const svg = imBlock.getSvgRoot(); - if (svg) { - svg.setAttribute('visibility', 'hidden'); - } - } - - /** - * Shows an outline around the input the closest connection belongs to. - * - * @param activeCandidate The connection that will be made if the drag ends - * immediately. - */ - private showInsertionInputOutline(activeCandidate: CandidateConnection) { - const closest = activeCandidate.closest; - this.highlightedBlock = closest.getSourceBlock(); - this.highlightedBlock.highlightShapeForInput(closest, true); - } - - /** Hides any visible input outlines. */ - private hideInsertionInputOutline() { - if (!this.highlightedBlock) return; - - if (!this.activeCandidate) { - throw new Error( - 'Cannot hide the insertion marker outline because ' + - 'there is no active candidate', - ); - } - this.highlightedBlock.highlightShapeForInput( - this.activeCandidate.closest, - false, - ); - this.highlightedBlock = null; - } - - /** - * Shows a replacement fade affect on the closest connection's target block - * (the block that is currently connected to it). - * - * @param activeCandidate The connection that will be made if the drag ends - * immediately. - */ - private showReplacementFade(activeCandidate: CandidateConnection) { - this.fadedBlock = activeCandidate.closest.targetBlock(); - if (!this.fadedBlock) { - throw new Error( - 'Cannot show the replacement fade because the ' + - 'closest connection does not have a target block', - ); - } - this.fadedBlock.fadeForReplacement(true); - } - - /** - * Hides/Removes any visible fade affects. - */ - private hideReplacementFade() { - if (!this.fadedBlock) return; - - this.fadedBlock.fadeForReplacement(false); - this.fadedBlock = null; - } - - /** - * Get a list of the insertion markers that currently exist. Drags have 0, 1, - * or 2 insertion markers. - * - * @returns A possibly empty list of insertion marker blocks. - * @internal - */ - getInsertionMarkers(): BlockSvg[] { - const result = []; - if (this.firstMarker) { - result.push(this.firstMarker); - } - if (this.lastMarker) { - result.push(this.lastMarker); - } - return result; - } - - /** - * Safely disposes of an insertion marker. - */ - private disposeInsertionMarker(marker: BlockSvg | null) { - if (marker) { - eventUtils.disable(); - try { - marker.dispose(); - } finally { - eventUtils.enable(); - } - } - } -} - -export namespace InsertionMarkerManager { - /** - * An enum describing different kinds of previews the InsertionMarkerManager - * could display. - */ - export enum PREVIEW_TYPE { - INSERTION_MARKER = 0, - INPUT_OUTLINE = 1, - REPLACEMENT_FADE = 2, - } -} - -export type PreviewType = InsertionMarkerManager.PREVIEW_TYPE; -export const PreviewType = InsertionMarkerManager.PREVIEW_TYPE; diff --git a/core/interfaces/i_ast_node_location.ts b/core/interfaces/i_ast_node_location.ts deleted file mode 100644 index cc90bbc4065..00000000000 --- a/core/interfaces/i_ast_node_location.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Former goog.module ID: Blockly.IASTNodeLocation - -/** - * An AST node location interface. - */ -export interface IASTNodeLocation {} diff --git a/core/interfaces/i_ast_node_location_svg.ts b/core/interfaces/i_ast_node_location_svg.ts deleted file mode 100644 index 729e5f09543..00000000000 --- a/core/interfaces/i_ast_node_location_svg.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Former goog.module ID: Blockly.IASTNodeLocationSvg - -import type {IASTNodeLocation} from './i_ast_node_location.js'; - -/** - * An AST node location SVG interface. - */ -export interface IASTNodeLocationSvg extends IASTNodeLocation { - /** - * Add the marker SVG to this node's SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the SVG group. - */ - setMarkerSvg(markerSvg: SVGElement | null): void; - - /** - * Add the cursor SVG to this node's SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the SVG group. - */ - setCursorSvg(cursorSvg: SVGElement | null): void; -} diff --git a/core/interfaces/i_ast_node_location_with_block.ts b/core/interfaces/i_ast_node_location_with_block.ts deleted file mode 100644 index b04234fd4a8..00000000000 --- a/core/interfaces/i_ast_node_location_with_block.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Former goog.module ID: Blockly.IASTNodeLocationWithBlock - -import type {Block} from '../block.js'; -import type {IASTNodeLocation} from './i_ast_node_location.js'; - -/** - * An AST node location that has an associated block. - */ -export interface IASTNodeLocationWithBlock extends IASTNodeLocation { - /** - * Get the source block associated with this node. - * - * @returns The source block. - */ - getSourceBlock(): Block | null; -} diff --git a/core/interfaces/i_autohideable.ts b/core/interfaces/i_autohideable.ts index ecdec8595a6..41e761f57ca 100644 --- a/core/interfaces/i_autohideable.ts +++ b/core/interfaces/i_autohideable.ts @@ -20,3 +20,8 @@ export interface IAutoHideable extends IComponent { */ autoHide(onlyClosePopups: boolean): void; } + +/** Returns true if the given object is autohideable. */ +export function isAutoHideable(obj: any): obj is IAutoHideable { + return obj.autoHide !== undefined; +} diff --git a/core/interfaces/i_bubble.ts b/core/interfaces/i_bubble.ts index d31ce9c9dce..553f86e9e9e 100644 --- a/core/interfaces/i_bubble.ts +++ b/core/interfaces/i_bubble.ts @@ -9,11 +9,12 @@ import type {Coordinate} from '../utils/coordinate.js'; import type {IContextMenu} from './i_contextmenu.js'; import type {IDraggable} from './i_draggable.js'; +import {IFocusableNode} from './i_focusable_node.js'; /** * A bubble interface. */ -export interface IBubble extends IDraggable, IContextMenu { +export interface IBubble extends IDraggable, IContextMenu, IFocusableNode { /** * Return the coordinates of the top-left corner of this bubble's body * relative to the drawing surface's origin (0,0), in workspace units. diff --git a/core/interfaces/i_comment_icon.ts b/core/interfaces/i_comment_icon.ts index 9801a8d6e11..05f86f40ff9 100644 --- a/core/interfaces/i_comment_icon.ts +++ b/core/interfaces/i_comment_icon.ts @@ -6,6 +6,7 @@ import {CommentState} from '../icons/comment_icon.js'; import {IconType} from '../icons/icon_types.js'; +import {Coordinate} from '../utils/coordinate.js'; import {Size} from '../utils/size.js'; import {IHasBubble, hasBubble} from './i_has_bubble.js'; import {IIcon, isIcon} from './i_icon.js'; @@ -20,6 +21,10 @@ export interface ICommentIcon extends IIcon, IHasBubble, ISerializable { getBubbleSize(): Size; + setBubbleLocation(location: Coordinate): void; + + getBubbleLocation(): Coordinate | undefined; + saveState(): CommentState; loadState(state: CommentState): void; @@ -35,6 +40,8 @@ export function isCommentIcon(obj: object): obj is ICommentIcon { (obj as any)['getText'] !== undefined && (obj as any)['setBubbleSize'] !== undefined && (obj as any)['getBubbleSize'] !== undefined && + (obj as any)['setBubbleLocation'] !== undefined && + (obj as any)['getBubbleLocation'] !== undefined && obj.getType() === IconType.COMMENT ); } diff --git a/core/interfaces/i_flyout.ts b/core/interfaces/i_flyout.ts index c79be344c5a..067cd5ef20d 100644 --- a/core/interfaces/i_flyout.ts +++ b/core/interfaces/i_flyout.ts @@ -7,17 +7,18 @@ // Former goog.module ID: Blockly.IFlyout import type {BlockSvg} from '../block_svg.js'; -import {FlyoutItem} from '../flyout_base.js'; +import type {FlyoutItem} from '../flyout_item.js'; import type {Coordinate} from '../utils/coordinate.js'; import type {Svg} from '../utils/svg.js'; import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; +import {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; /** * Interface for a flyout. */ -export interface IFlyout extends IRegistrable { +export interface IFlyout extends IRegistrable, IFocusableTree { /** Whether the flyout is laid out horizontally or not. */ horizontalLayout: boolean; diff --git a/core/interfaces/i_flyout_inflater.ts b/core/interfaces/i_flyout_inflater.ts new file mode 100644 index 00000000000..e3c1f5db48f --- /dev/null +++ b/core/interfaces/i_flyout_inflater.ts @@ -0,0 +1,51 @@ +import type {FlyoutItem} from '../flyout_item.js'; +import type {IFlyout} from './i_flyout.js'; + +export interface IFlyoutInflater { + /** + * Loads the object represented by the given state onto the workspace. + * + * Note that this method's interface is identical to that in ISerializer, to + * allow for code reuse. + * + * @param state A JSON representation of an element to inflate on the flyout. + * @param flyout The flyout on whose workspace the inflated element + * should be created. If the inflated element is an `IRenderedElement` it + * itself or the inflater should append it to the workspace; the flyout + * will not do so itself. The flyout is responsible for positioning the + * element, however. + * @returns The newly inflated flyout element. + */ + load(state: object, flyout: IFlyout): FlyoutItem; + + /** + * Returns the amount of spacing that should follow the element corresponding + * to the given JSON representation. + * + * @param state A JSON representation of the element preceding the gap. + * @param defaultGap The default gap for elements in this flyout. + * @returns The gap that should follow the given element. + */ + gapForItem(state: object, defaultGap: number): number; + + /** + * Disposes of the given element. + * + * If the element in question resides on the flyout workspace, it should remove + * itself. Implementers are not otherwise required to fully dispose of the + * element; it may be e.g. cached for performance purposes. + * + * @param element The flyout element to dispose of. + */ + disposeItem(item: FlyoutItem): void; + + /** + * Returns the type of items that this inflater is responsible for inflating. + * This should be the same as the name under which this inflater registers + * itself, as well as the value returned by `getType()` on the `FlyoutItem` + * objects returned by `load()`. + * + * @returns The type of items this inflater creates. + */ + getType(): string; +} diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts new file mode 100644 index 00000000000..b21d7741a5c --- /dev/null +++ b/core/interfaces/i_focusable_node.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableTree} from './i_focusable_tree.js'; + +/** Represents anything that can have input focus. */ +export interface IFocusableNode { + /** + * Returns the DOM element that can be explicitly requested to receive focus. + * + * IMPORTANT: Please note that this element is expected to have a visual + * presence on the page as it will both be explicitly focused and have its + * style changed depending on its current focus state (i.e. blurred, actively + * focused, and passively focused). The element will have one of two styles + * attached (where no style indicates blurred/not focused): + * - blocklyActiveFocus + * - blocklyPassiveFocus + * + * The returned element must also have a valid ID specified, and unique across + * the entire page. Failing to have a properly unique ID could result in + * trying to focus one node (such as via a mouse click) leading to another + * node with the same ID actually becoming focused by FocusManager. The + * returned element must also have a negative tabindex (since the focus + * manager itself will manage its tab index and a tab index must be present in + * order for the element to be focusable in the DOM). + * + * The returned element must be visible if the node is ever focused via + * FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an + * element to be hidden until onNodeFocus() is called, or become hidden with a + * call to onNodeBlur(). + * + * It's expected the actual returned element will not change for the lifetime + * of the node (that is, its properties can change but a new element should + * never be returned). + * + * @returns The HTMLElement or SVGElement which can both receive focus and be + * visually represented as actively or passively focused for this node. + */ + getFocusableElement(): HTMLElement | SVGElement; + + /** + * Returns the closest parent tree of this node (in cases where a tree has + * distinct trees underneath it), which represents the tree to which this node + * belongs. + * + * @returns The node's IFocusableTree. + */ + getFocusableTree(): IFocusableTree; + + /** + * Called when this node receives active focus. + * + * Note that it's fine for implementations to change visibility modifiers, but + * they should avoid the following: + * - Creating or removing DOM elements (including via the renderer or drawer). + * - Affecting focus via DOM focus() calls or the FocusManager. + */ + onNodeFocus(): void; + + /** + * Called when this node loses active focus. It may still have passive focus. + * + * This has the same implementation restrictions as onNodeFocus(). + */ + onNodeBlur(): void; + + /** + * Indicates whether this node allows focus. If this returns false then none + * of the other IFocusableNode methods will be called. + * + * Note that special care must be taken if implementations of this function + * dynamically change their return value value over the lifetime of the node + * as certain environment conditions could affect the focusability of this + * node's DOM element (such as whether the element has a positive or zero + * tabindex). Also, changing from a true to a false value while the node holds + * focus will not immediately change the current focus of the node nor + * FocusManager's internal state, and thus may result in some of the node's + * functions being called later on when defocused (since it was previously + * considered focusable at the time of being focused). + * + * Implementations should generally always return true here unless there are + * circumstances under which this node should be skipped for focus + * considerations. Examples may include being disabled, read-only, a purely + * visual decoration, or a node with no visual representation that must + * implement this interface (e.g. due to a parent interface extending it). + * Keep in mind accessibility best practices when determining whether a node + * should be focusable since even disabled and read-only elements are still + * often relevant to providing organizational context to users (particularly + * when using a screen reader). + * + * @returns Whether this node can be focused by FocusManager. + */ + canBeFocused(): boolean; +} + +/** + * Determines whether the provided object fulfills the contract of + * IFocusableNode. + * + * @param object The object to test. + * @returns Whether the provided object can be used as an IFocusableNode. + */ +export function isFocusableNode(object: any | null): object is IFocusableNode { + return ( + object && + 'getFocusableElement' in object && + 'getFocusableTree' in object && + 'onNodeFocus' in object && + 'onNodeBlur' in object && + 'canBeFocused' in object + ); +} diff --git a/core/interfaces/i_focusable_tree.ts b/core/interfaces/i_focusable_tree.ts new file mode 100644 index 00000000000..f4f25f7f518 --- /dev/null +++ b/core/interfaces/i_focusable_tree.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './i_focusable_node.js'; + +/** + * Represents a tree of focusable elements with its own active/passive focus + * context. + * + * Note that focus is handled by FocusManager, and tree implementations can have + * at most one IFocusableNode focused at one time. If the tree itself has focus, + * then the tree's focused node is considered 'active' ('passive' if another + * tree has focus). + * + * Focus is shared between one or more trees, where each tree can have exactly + * one active or passive node (and only one active node can exist on the whole + * page at any given time). The idea of passive focus is to provide context to + * users on where their focus will be restored upon navigating back to a + * previously focused tree. + * + * Note that if the tree's current focused node (passive or active) is needed, + * FocusableTreeTraverser.findFocusedNode can be used. + * + * Note that if specific nodes are needed to be retrieved for this tree, either + * use lookUpFocusableNode or FocusableTreeTraverser.findFocusableNodeFor. + */ +export interface IFocusableTree { + /** + * Returns the top-level focusable node of the tree. + * + * It's expected that the returned node will be focused in cases where + * FocusManager wants to focus a tree in a situation where it does not + * currently have a focused node. + */ + getRootFocusableNode(): IFocusableNode; + + /** + * Returns the IFocusableNode of this tree that should receive active focus + * when the tree itself has focus returned to it. + * + * There are some very important notes to consider about a tree's focus + * lifecycle when implementing a version of this method that doesn't return + * null: + * 1. A null previousNode does not guarantee first-time focus state as nodes + * can be deleted. + * 2. This method is only used when the tree itself is focused, either through + * tab navigation or via FocusManager.focusTree(). In many cases, the + * previously focused node will be directly focused instead which will + * bypass this method. + * 3. The default behavior (i.e. returning null here) involves either + * restoring the previous node (previousNode) or focusing the tree's root. + * 4. The provided node may sometimes no longer be valid, such as in the case + * an attempt is made to focus a node that has been recently removed from + * its parent tree. Implementations can check for the validity of the node + * in order to specialize the node to which focus should fall back. + * + * This method is largely intended to provide tree implementations with the + * means of specifying a better default node than their root. + * + * @param previousNode The node that previously held passive focus for this + * tree, or null if the tree hasn't yet been focused. + * @returns The IFocusableNode that should now receive focus, or null if + * default behavior should be used, instead. + */ + getRestoredFocusableNode( + previousNode: IFocusableNode | null, + ): IFocusableNode | null; + + /** + * Returns all directly nested trees under this tree. + * + * Note that the returned list of trees doesn't need to be stable, however all + * returned trees *do* need to be registered with FocusManager. Additionally, + * this must return actual nested trees as omitting a nested tree will affect + * how focus changes map to a specific node and its tree, potentially leading + * to user confusion. + */ + getNestedTrees(): Array; + + /** + * Returns the IFocusableNode corresponding to the specified element ID, or + * null if there's no exact node within this tree with that ID or if the ID + * corresponds to the root of the tree. + * + * This will never match against nested trees. + * + * @param id The ID of the node's focusable HTMLElement or SVGElement. + */ + lookUpFocusableNode(id: string): IFocusableNode | null; + + /** + * Called when a node of this tree has received active focus. + * + * Note that a null previousTree does not necessarily indicate that this is + * the first time Blockly is receiving focus. In fact, few assumptions can be + * made about previous focus state as a previous null tree simply indicates + * that Blockly did not hold active focus prior to this tree becoming focused + * (which can happen due to focus exiting the Blockly injection div, or for + * other cases like ephemeral focus). + * + * See IFocusableNode.onNodeFocus() as implementations have the same + * restrictions as with that method. + * + * @param node The node receiving active focus. + * @param previousTree The previous tree that held active focus, or null if + * none. + */ + onTreeFocus(node: IFocusableNode, previousTree: IFocusableTree | null): void; + + /** + * Called when the previously actively focused node of this tree is now + * passively focused and there is no other active node of this tree taking its + * place. + * + * This has the same implementation restrictions and considerations as + * onTreeFocus(). + * + * @param nextTree The next tree receiving active focus, or null if none (such + * as in the case that Blockly is entirely losing DOM focus). + */ + onTreeBlur(nextTree: IFocusableTree | null): void; +} + +/** + * Determines whether the provided object fulfills the contract of + * IFocusableTree. + * + * @param object The object to test. + * @returns Whether the provided object can be used as an IFocusableTree. + */ +export function isFocusableTree(object: any | null): object is IFocusableTree { + return ( + object && + 'getRootFocusableNode' in object && + 'getRestoredFocusableNode' in object && + 'getNestedTrees' in object && + 'lookUpFocusableNode' in object && + 'onTreeFocus' in object && + 'onTreeBlur' in object + ); +} diff --git a/core/interfaces/i_has_bubble.ts b/core/interfaces/i_has_bubble.ts index 276feff21e2..85c6f099031 100644 --- a/core/interfaces/i_has_bubble.ts +++ b/core/interfaces/i_has_bubble.ts @@ -4,12 +4,27 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {IBubble} from './i_bubble'; + export interface IHasBubble { /** @returns True if the bubble is currently open, false otherwise. */ bubbleIsVisible(): boolean; /** Sets whether the bubble is open or not. */ setBubbleVisible(visible: boolean): Promise; + + /** + * Returns the current IBubble that implementations are managing, or null if + * there isn't one. + * + * Note that this cannot be expected to return null if bubbleIsVisible() + * returns false, i.e., the nullability of the returned bubble does not + * necessarily imply visibility. + * + * @returns The current IBubble maintained by implementations, or null if + * there is not one. + */ + getBubble(): IBubble | null; } /** Type guard that checks whether the given object is a IHasBubble. */ diff --git a/core/interfaces/i_icon.ts b/core/interfaces/i_icon.ts index a6159985f91..74489dc5e09 100644 --- a/core/interfaces/i_icon.ts +++ b/core/interfaces/i_icon.ts @@ -7,8 +7,9 @@ import type {IconType} from '../icons/icon_types.js'; import type {Coordinate} from '../utils/coordinate.js'; import type {Size} from '../utils/size.js'; +import {IFocusableNode, isFocusableNode} from './i_focusable_node.js'; -export interface IIcon { +export interface IIcon extends IFocusableNode { /** * @returns the IconType representing the type of the icon. This value should * also be used to register the icon via `Blockly.icons.registry.register`. @@ -109,6 +110,7 @@ export function isIcon(obj: any): obj is IIcon { obj.isShownWhenCollapsed !== undefined && obj.setOffsetInBlock !== undefined && obj.onLocationChange !== undefined && - obj.onClick !== undefined + obj.onClick !== undefined && + isFocusableNode(obj) ); } diff --git a/core/interfaces/i_metrics_manager.ts b/core/interfaces/i_metrics_manager.ts index bb4d54da440..6fc0d080cc2 100644 --- a/core/interfaces/i_metrics_manager.ts +++ b/core/interfaces/i_metrics_manager.ts @@ -63,7 +63,7 @@ export interface IMetricsManager { * Gets the width, height and position of the toolbox on the workspace in * pixel coordinates. Returns 0 for the width and height if the workspace has * a simple toolbox instead of a category toolbox. To get the width and height - * of a simple toolbox, see {@link IMetricsManager#getFlyoutMetrics}. + * of a simple toolbox, see {@link IMetricsManager.getFlyoutMetrics}. * * @returns The object with the width, height and position of the toolbox. */ diff --git a/core/interfaces/i_navigation_policy.ts b/core/interfaces/i_navigation_policy.ts new file mode 100644 index 00000000000..8e1ce6c1005 --- /dev/null +++ b/core/interfaces/i_navigation_policy.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './i_focusable_node.js'; + +/** + * A set of rules that specify where keyboard navigation should proceed. + */ +export interface INavigationPolicy { + /** + * Returns the first child element of the given element, if any. + * + * @param current The element which the user is navigating into. + * @returns The current element's first child, or null if it has none. + */ + getFirstChild(current: T): IFocusableNode | null; + + /** + * Returns the parent element of the given element, if any. + * + * @param current The element which the user is navigating out of. + * @returns The parent element of the current element, or null if it has none. + */ + getParent(current: T): IFocusableNode | null; + + /** + * Returns the peer element following the given element, if any. + * + * @param current The element which the user is navigating past. + * @returns The next peer element of the current element, or null if there is + * none. + */ + getNextSibling(current: T): IFocusableNode | null; + + /** + * Returns the peer element preceding the given element, if any. + * + * @param current The element which the user is navigating past. + * @returns The previous peer element of the current element, or null if + * there is none. + */ + getPreviousSibling(current: T): IFocusableNode | null; + + /** + * Returns whether or not the given instance should be reachable via keyboard + * navigation. + * + * Implementors should generally return true, unless there are circumstances + * under which this item should be skipped while using keyboard navigation. + * Common examples might include being disabled, invalid, readonly, or purely + * a visual decoration. For example, while Fields are navigable, non-editable + * fields return false, since they cannot be interacted with when focused. + * + * @returns True if this element should be included in keyboard navigation. + */ + isNavigable(current: T): boolean; + + /** + * Returns whether or not this navigation policy corresponds to the type of + * the given object. + * + * @param current An instance to check whether this policy applies to. + * @returns True if the given object is of a type handled by this policy. + */ + isApplicable(current: any): current is T; +} diff --git a/core/interfaces/i_rendered_element.ts b/core/interfaces/i_rendered_element.ts index 7e6981ca6b1..fe9460c7f6a 100644 --- a/core/interfaces/i_rendered_element.ts +++ b/core/interfaces/i_rendered_element.ts @@ -4,18 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @internal */ export interface IRenderedElement { /** - * @returns The root SVG element of htis rendered element. + * @returns The root SVG element of this rendered element. */ getSvgRoot(): SVGElement; } /** * @returns True if the given object is an IRenderedElement. - * - * @internal */ export function isRenderedElement(obj: any): obj is IRenderedElement { return obj['getSvgRoot'] !== undefined; diff --git a/core/interfaces/i_selectable.ts b/core/interfaces/i_selectable.ts index 972b0adb107..639972e45cb 100644 --- a/core/interfaces/i_selectable.ts +++ b/core/interfaces/i_selectable.ts @@ -7,11 +7,17 @@ // Former goog.module ID: Blockly.ISelectable import type {Workspace} from '../workspace.js'; +import {IFocusableNode, isFocusableNode} from './i_focusable_node.js'; /** * The interface for an object that is selectable. + * + * Implementations are generally expected to use their implementations of + * onNodeFocus() and onNodeBlur() to call setSelected() with themselves and + * null, respectively, in order to ensure that selections are correctly updated + * and the selection change event is fired. */ -export interface ISelectable { +export interface ISelectable extends IFocusableNode { id: string; workspace: Workspace; @@ -29,6 +35,7 @@ export function isSelectable(obj: object): obj is ISelectable { typeof (obj as any).id === 'string' && (obj as any).workspace !== undefined && (obj as any).select !== undefined && - (obj as any).unselect !== undefined + (obj as any).unselect !== undefined && + isFocusableNode(obj) ); } diff --git a/core/interfaces/i_toolbox.ts b/core/interfaces/i_toolbox.ts index 2756099ec34..f5d9c9fd7c6 100644 --- a/core/interfaces/i_toolbox.ts +++ b/core/interfaces/i_toolbox.ts @@ -9,13 +9,14 @@ import type {ToolboxInfo} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import type {IFlyout} from './i_flyout.js'; +import type {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; import type {IToolboxItem} from './i_toolbox_item.js'; /** * Interface for a toolbox. */ -export interface IToolbox extends IRegistrable { +export interface IToolbox extends IRegistrable, IFocusableTree { /** Initializes the toolbox. */ init(): void; @@ -94,7 +95,7 @@ export interface IToolbox extends IRegistrable { setVisible(isVisible: boolean): void; /** - * Selects the toolbox item by it's position in the list of toolbox items. + * Selects the toolbox item by its position in the list of toolbox items. * * @param position The position of the item to select. */ @@ -107,6 +108,14 @@ export interface IToolbox extends IRegistrable { */ getSelectedItem(): IToolboxItem | null; + /** + * Sets the selected item. + * + * @param item The toolbox item to select, or null to remove the current + * selection. + */ + setSelectedItem(item: IToolboxItem | null): void; + /** Disposes of this toolbox. */ dispose(): void; } diff --git a/core/interfaces/i_toolbox_item.ts b/core/interfaces/i_toolbox_item.ts index e3c9864f0c0..661624fd7e8 100644 --- a/core/interfaces/i_toolbox_item.ts +++ b/core/interfaces/i_toolbox_item.ts @@ -6,10 +6,12 @@ // Former goog.module ID: Blockly.IToolboxItem +import type {IFocusableNode} from './i_focusable_node.js'; + /** * Interface for an item in the toolbox. */ -export interface IToolboxItem { +export interface IToolboxItem extends IFocusableNode { /** * Initializes the toolbox item. * This includes creating the DOM and updating the state of any items based diff --git a/core/interfaces/i_variable_backed_parameter_model.ts b/core/interfaces/i_variable_backed_parameter_model.ts index b2042bfb2f5..444deb60105 100644 --- a/core/interfaces/i_variable_backed_parameter_model.ts +++ b/core/interfaces/i_variable_backed_parameter_model.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {VariableModel} from '../variable_model.js'; import {IParameterModel} from './i_parameter_model.js'; +import type {IVariableModel, IVariableState} from './i_variable_model.js'; /** Interface for a parameter model that holds a variable model. */ export interface IVariableBackedParameterModel extends IParameterModel { /** Returns the variable model held by this type. */ - getVariableModel(): VariableModel; + getVariableModel(): IVariableModel; } /** diff --git a/core/interfaces/i_variable_map.ts b/core/interfaces/i_variable_map.ts new file mode 100644 index 00000000000..6c21aa8e0cb --- /dev/null +++ b/core/interfaces/i_variable_map.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IVariableModel, IVariableState} from './i_variable_model.js'; + +/** + * Variable maps are container objects responsible for storing and managing the + * set of variables referenced on a workspace. + * + * Any of these methods may define invariants about which names and types are + * legal, and throw if they are not met. + */ +export interface IVariableMap> { + /* Returns the variable corresponding to the given ID, or null if none. */ + getVariableById(id: string): T | null; + + /** + * Returns the variable with the given name, or null if not found. If `type` + * is provided, the variable's type must also match, or null should be + * returned. + */ + getVariable(name: string, type?: string): T | null; + + /* Returns a list of all variables managed by this variable map. */ + getAllVariables(): T[]; + + /** + * Returns a list of all of the variables of the given type managed by this + * variable map. + */ + getVariablesOfType(type: string): T[]; + + /** + * Returns a list of the set of types of the variables managed by this + * variable map. + */ + getTypes(): string[]; + + /** + * Creates a new variable with the given name. If ID is not specified, the + * variable map should create one. Returns the new variable. + */ + createVariable(name: string, id?: string, type?: string | null): T; + + /* Adds a variable to this variable map. */ + addVariable(variable: T): void; + + /** + * Changes the name of the given variable to the name provided and returns the + * renamed variable. + */ + renameVariable(variable: T, newName: string): T; + + /* Changes the type of the given variable and returns it. */ + changeVariableType(variable: T, newType: string): T; + + /* Deletes the given variable. */ + deleteVariable(variable: T): void; + + /* Removes all variables from this variable map. */ + clear(): void; +} diff --git a/core/interfaces/i_variable_model.ts b/core/interfaces/i_variable_model.ts new file mode 100644 index 00000000000..791b1072567 --- /dev/null +++ b/core/interfaces/i_variable_model.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Workspace} from '../workspace.js'; + +/* Representation of a variable. */ +export interface IVariableModel { + /* Returns the unique ID of this variable. */ + getId(): string; + + /* Returns the user-visible name of this variable. */ + getName(): string; + + /** + * Returns the type of the variable like 'int' or 'string'. Does not need to be + * unique. This will default to '' which is a specific type. + */ + getType(): string; + + /* Sets the user-visible name of this variable. */ + setName(name: string): this; + + /* Sets the type of this variable. */ + setType(type: string): this; + + getWorkspace(): Workspace; + + /* Serializes this variable */ + save(): T; +} + +export interface IVariableModelStatic { + new ( + workspace: Workspace, + name: string, + type?: string, + id?: string, + ): IVariableModel; + + /** + * Creates a new IVariableModel corresponding to the given state on the + * specified workspace. This method must be static in your implementation. + */ + load(state: T, workspace: Workspace): IVariableModel; +} + +/** + * Represents the state of a given variable. + */ +export interface IVariableState { + name: string; + id: string; + type?: string; +} diff --git a/core/keyboard_nav/ast_node.ts b/core/keyboard_nav/ast_node.ts deleted file mode 100644 index 3b0efae3fb3..00000000000 --- a/core/keyboard_nav/ast_node.ts +++ /dev/null @@ -1,880 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing an AST node. - * Used to traverse the Blockly AST. - * - * @class - */ -// Former goog.module ID: Blockly.ASTNode - -import {Block} from '../block.js'; -import type {Connection} from '../connection.js'; -import {ConnectionType} from '../connection_type.js'; -import type {Field} from '../field.js'; -import {FlyoutItem} from '../flyout_base.js'; -import {FlyoutButton} from '../flyout_button.js'; -import type {Input} from '../inputs/input.js'; -import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js'; -import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js'; -import {Coordinate} from '../utils/coordinate.js'; -import type {Workspace} from '../workspace.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; - -/** - * Class for an AST node. - * It is recommended that you use one of the createNode methods instead of - * creating a node directly. - */ -export class ASTNode { - /** - * True to navigate to all fields. False to only navigate to clickable fields. - */ - static NAVIGATE_ALL_FIELDS = false; - - /** - * The default y offset to use when moving the cursor from a stack to the - * workspace. - */ - private static readonly DEFAULT_OFFSET_Y: number = -20; - private readonly type: string; - private readonly isConnectionLocation: boolean; - private readonly location: IASTNodeLocation; - - /** The coordinate on the workspace. */ - // AnyDuringMigration because: Type 'null' is not assignable to type - // 'Coordinate'. - private wsCoordinate: Coordinate = null as AnyDuringMigration; - - /** - * @param type The type of the location. - * Must be in ASTNode.types. - * @param location The position in the AST. - * @param opt_params Optional dictionary of options. - */ - constructor(type: string, location: IASTNodeLocation, opt_params?: Params) { - if (!location) { - throw Error('Cannot create a node without a location.'); - } - - /** - * The type of the location. - * One of ASTNode.types - */ - this.type = type; - - /** Whether the location points to a connection. */ - this.isConnectionLocation = ASTNode.isConnectionType(type); - - /** The location of the AST node. */ - this.location = location; - - this.processParams(opt_params || null); - } - - /** - * Parse the optional parameters. - * - * @param params The user specified parameters. - */ - private processParams(params: Params | null) { - if (!params) { - return; - } - if (params.wsCoordinate) { - this.wsCoordinate = params.wsCoordinate; - } - } - - /** - * Gets the value pointed to by this node. - * It is the callers responsibility to check the node type to figure out what - * type of object they get back from this. - * - * @returns The current field, connection, workspace, or block the cursor is - * on. - */ - getLocation(): IASTNodeLocation { - return this.location; - } - - /** - * The type of the current location. - * One of ASTNode.types - * - * @returns The type of the location. - */ - getType(): string { - return this.type; - } - - /** - * The coordinate on the workspace. - * - * @returns The workspace coordinate or null if the location is not a - * workspace. - */ - getWsCoordinate(): Coordinate { - return this.wsCoordinate; - } - - /** - * Whether the node points to a connection. - * - * @returns [description] - * @internal - */ - isConnection(): boolean { - return this.isConnectionLocation; - } - - /** - * Given an input find the next editable field or an input with a non null - * connection in the same block. The current location must be an input - * connection. - * - * @returns The AST node holding the next field or connection or null if there - * is no editable field or input connection after the given input. - */ - private findNextForInput(): ASTNode | null { - const location = this.location as Connection; - const parentInput = location.getParentInput(); - const block = parentInput!.getSourceBlock(); - // AnyDuringMigration because: Argument of type 'Input | null' is not - // assignable to parameter of type 'Input'. - const curIdx = block!.inputList.indexOf(parentInput as AnyDuringMigration); - for (let i = curIdx + 1; i < block!.inputList.length; i++) { - const input = block!.inputList[i]; - const fieldRow = input.fieldRow; - for (let j = 0; j < fieldRow.length; j++) { - const field = fieldRow[j]; - if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { - return ASTNode.createFieldNode(field); - } - } - if (input.connection) { - return ASTNode.createInputNode(input); - } - } - return null; - } - - /** - * Given a field find the next editable field or an input with a non null - * connection in the same block. The current location must be a field. - * - * @returns The AST node pointing to the next field or connection or null if - * there is no editable field or input connection after the given input. - */ - private findNextForField(): ASTNode | null { - const location = this.location as Field; - const input = location.getParentInput(); - const block = location.getSourceBlock(); - if (!block) { - throw new Error( - 'The current AST location is not associated with a block', - ); - } - const curIdx = block.inputList.indexOf(input); - let fieldIdx = input.fieldRow.indexOf(location) + 1; - for (let i = curIdx; i < block.inputList.length; i++) { - const newInput = block.inputList[i]; - const fieldRow = newInput.fieldRow; - while (fieldIdx < fieldRow.length) { - if (fieldRow[fieldIdx].isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { - return ASTNode.createFieldNode(fieldRow[fieldIdx]); - } - fieldIdx++; - } - fieldIdx = 0; - if (newInput.connection) { - return ASTNode.createInputNode(newInput); - } - } - return null; - } - - /** - * Given an input find the previous editable field or an input with a non null - * connection in the same block. The current location must be an input - * connection. - * - * @returns The AST node holding the previous field or connection. - */ - private findPrevForInput(): ASTNode | null { - const location = this.location as Connection; - const parentInput = location.getParentInput(); - const block = parentInput!.getSourceBlock(); - // AnyDuringMigration because: Argument of type 'Input | null' is not - // assignable to parameter of type 'Input'. - const curIdx = block!.inputList.indexOf(parentInput as AnyDuringMigration); - for (let i = curIdx; i >= 0; i--) { - const input = block!.inputList[i]; - if (input.connection && input !== parentInput) { - return ASTNode.createInputNode(input); - } - const fieldRow = input.fieldRow; - for (let j = fieldRow.length - 1; j >= 0; j--) { - const field = fieldRow[j]; - if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { - return ASTNode.createFieldNode(field); - } - } - } - return null; - } - - /** - * Given a field find the previous editable field or an input with a non null - * connection in the same block. The current location must be a field. - * - * @returns The AST node holding the previous input or field. - */ - private findPrevForField(): ASTNode | null { - const location = this.location as Field; - const parentInput = location.getParentInput(); - const block = location.getSourceBlock(); - if (!block) { - throw new Error( - 'The current AST location is not associated with a block', - ); - } - const curIdx = block.inputList.indexOf(parentInput); - let fieldIdx = parentInput.fieldRow.indexOf(location) - 1; - for (let i = curIdx; i >= 0; i--) { - const input = block.inputList[i]; - if (input.connection && input !== parentInput) { - return ASTNode.createInputNode(input); - } - const fieldRow = input.fieldRow; - while (fieldIdx > -1) { - if (fieldRow[fieldIdx].isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { - return ASTNode.createFieldNode(fieldRow[fieldIdx]); - } - fieldIdx--; - } - // Reset the fieldIdx to the length of the field row of the previous - // input. - if (i - 1 >= 0) { - fieldIdx = block.inputList[i - 1].fieldRow.length - 1; - } - } - return null; - } - - /** - * Navigate between stacks of blocks on the workspace. - * - * @param forward True to go forward. False to go backwards. - * @returns The first block of the next stack or null if there are no blocks - * on the workspace. - */ - private navigateBetweenStacks(forward: boolean): ASTNode | null { - let curLocation = this.getLocation(); - // TODO(#6097): Use instanceof checks to exit early for values of - // curLocation that don't make sense. - if ((curLocation as IASTNodeLocationWithBlock).getSourceBlock) { - const block = (curLocation as IASTNodeLocationWithBlock).getSourceBlock(); - if (block) { - curLocation = block; - } - } - // TODO(#6097): Use instanceof checks to exit early for values of - // curLocation that don't make sense. - const curLocationAsBlock = curLocation as Block; - if (!curLocationAsBlock || curLocationAsBlock.isDeadOrDying()) { - return null; - } - if (curLocationAsBlock.workspace.isFlyout) { - return this.navigateFlyoutContents(forward); - } - const curRoot = curLocationAsBlock.getRootBlock(); - const topBlocks = curRoot.workspace.getTopBlocks(true); - for (let i = 0; i < topBlocks.length; i++) { - const topBlock = topBlocks[i]; - if (curRoot.id === topBlock.id) { - const offset = forward ? 1 : -1; - const resultIndex = i + offset; - if (resultIndex === -1 || resultIndex === topBlocks.length) { - return null; - } - return ASTNode.createStackNode(topBlocks[resultIndex]); - } - } - throw Error( - "Couldn't find " + (forward ? 'next' : 'previous') + ' stack?!', - ); - } - - /** - * Navigate between buttons and stacks of blocks on the flyout workspace. - * - * @param forward True to go forward. False to go backwards. - * @returns The next button, or next stack's first block, or null - */ - private navigateFlyoutContents(forward: boolean): ASTNode | null { - const nodeType = this.getType(); - let location; - let targetWorkspace; - - switch (nodeType) { - case ASTNode.types.STACK: { - location = this.getLocation() as Block; - const workspace = location.workspace as WorkspaceSvg; - targetWorkspace = workspace.targetWorkspace as WorkspaceSvg; - break; - } - case ASTNode.types.BUTTON: { - location = this.getLocation() as FlyoutButton; - targetWorkspace = location.getTargetWorkspace() as WorkspaceSvg; - break; - } - default: - return null; - } - - const flyout = targetWorkspace.getFlyout(); - if (!flyout) return null; - - const nextItem = this.findNextLocationInFlyout( - flyout.getContents(), - location, - forward, - ); - if (!nextItem) return null; - - if (nextItem.type === 'button' && nextItem.button) { - return ASTNode.createButtonNode(nextItem.button); - } else if (nextItem.type === 'block' && nextItem.block) { - return ASTNode.createStackNode(nextItem.block); - } - - return null; - } - - /** - * Finds the next (or previous if navigating backward) item in the flyout that should be navigated to. - * - * @param flyoutContents Contents of the current flyout. - * @param currentLocation Current ASTNode location. - * @param forward True if we're navigating forward, else false. - * @returns The next (or previous) FlyoutItem, or null if there is none. - */ - private findNextLocationInFlyout( - flyoutContents: FlyoutItem[], - currentLocation: IASTNodeLocation, - forward: boolean, - ): FlyoutItem | null { - const currentIndex = flyoutContents.findIndex((item: FlyoutItem) => { - if (currentLocation instanceof Block && item.block === currentLocation) { - return true; - } - if ( - currentLocation instanceof FlyoutButton && - item.button === currentLocation - ) { - return true; - } - return false; - }); - - if (currentIndex < 0) return null; - - const resultIndex = forward ? currentIndex + 1 : currentIndex - 1; - if (resultIndex === -1 || resultIndex === flyoutContents.length) { - return null; - } - - return flyoutContents[resultIndex]; - } - - /** - * Finds the top most AST node for a given block. - * This is either the previous connection, output connection or block - * depending on what kind of connections the block has. - * - * @param block The block that we want to find the top connection on. - * @returns The AST node containing the top connection. - */ - private findTopASTNodeForBlock(block: Block): ASTNode | null { - const topConnection = getParentConnection(block); - if (topConnection) { - return ASTNode.createConnectionNode(topConnection); - } else { - return ASTNode.createBlockNode(block); - } - } - - /** - * Get the AST node pointing to the input that the block is nested under or if - * the block is not nested then get the stack AST node. - * - * @param block The source block of the current location. - * @returns The AST node pointing to the input connection or the top block of - * the stack this block is in. - */ - private getOutAstNodeForBlock(block: Block): ASTNode | null { - if (!block) { - return null; - } - // If the block doesn't have a previous connection then it is the top of the - // substack. - const topBlock = block.getTopStackBlock(); - const topConnection = getParentConnection(topBlock); - // If the top connection has a parentInput, create an AST node pointing to - // that input. - if ( - topConnection && - topConnection.targetConnection && - topConnection.targetConnection.getParentInput() - ) { - // AnyDuringMigration because: Argument of type 'Input | null' is not - // assignable to parameter of type 'Input'. - return ASTNode.createInputNode( - topConnection.targetConnection.getParentInput() as AnyDuringMigration, - ); - } else { - // Go to stack level if you are not underneath an input. - return ASTNode.createStackNode(topBlock); - } - } - - /** - * Find the first editable field or input with a connection on a given block. - * - * @param block The source block of the current location. - * @returns An AST node pointing to the first field or input. - * Null if there are no editable fields or inputs with connections on the - * block. - */ - private findFirstFieldOrInput(block: Block): ASTNode | null { - const inputs = block.inputList; - for (let i = 0; i < inputs.length; i++) { - const input = inputs[i]; - const fieldRow = input.fieldRow; - for (let j = 0; j < fieldRow.length; j++) { - const field = fieldRow[j]; - if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { - return ASTNode.createFieldNode(field); - } - } - if (input.connection) { - return ASTNode.createInputNode(input); - } - } - return null; - } - - /** - * Finds the source block of the location of this node. - * - * @returns The source block of the location, or null if the node is of type - * workspace or button. - */ - getSourceBlock(): Block | null { - if (this.getType() === ASTNode.types.BLOCK) { - return this.getLocation() as Block; - } else if (this.getType() === ASTNode.types.STACK) { - return this.getLocation() as Block; - } else if (this.getType() === ASTNode.types.WORKSPACE) { - return null; - } else if (this.getType() === ASTNode.types.BUTTON) { - return null; - } else { - return (this.getLocation() as IASTNodeLocationWithBlock).getSourceBlock(); - } - } - - /** - * Find the element to the right of the current element in the AST. - * - * @returns An AST node that wraps the next field, connection, block, or - * workspace. Or null if there is no node to the right. - */ - next(): ASTNode | null { - switch (this.type) { - case ASTNode.types.STACK: - return this.navigateBetweenStacks(true); - - case ASTNode.types.OUTPUT: { - const connection = this.location as Connection; - return ASTNode.createBlockNode(connection.getSourceBlock()); - } - case ASTNode.types.FIELD: - return this.findNextForField(); - - case ASTNode.types.INPUT: - return this.findNextForInput(); - - case ASTNode.types.BLOCK: { - const block = this.location as Block; - const nextConnection = block.nextConnection; - if (!nextConnection) return null; - return ASTNode.createConnectionNode(nextConnection); - } - case ASTNode.types.PREVIOUS: { - const connection = this.location as Connection; - return ASTNode.createBlockNode(connection.getSourceBlock()); - } - case ASTNode.types.NEXT: { - const connection = this.location as Connection; - const targetConnection = connection.targetConnection; - return ASTNode.createConnectionNode(targetConnection!); - } - case ASTNode.types.BUTTON: - return this.navigateFlyoutContents(true); - } - - return null; - } - - /** - * Find the element one level below and all the way to the left of the current - * location. - * - * @returns An AST node that wraps the next field, connection, workspace, or - * block. Or null if there is nothing below this node. - */ - in(): ASTNode | null { - switch (this.type) { - case ASTNode.types.WORKSPACE: { - const workspace = this.location as Workspace; - const topBlocks = workspace.getTopBlocks(true); - if (topBlocks.length > 0) { - return ASTNode.createStackNode(topBlocks[0]); - } - break; - } - case ASTNode.types.STACK: { - const block = this.location as Block; - return this.findTopASTNodeForBlock(block); - } - case ASTNode.types.BLOCK: { - const block = this.location as Block; - return this.findFirstFieldOrInput(block); - } - case ASTNode.types.INPUT: { - const connection = this.location as Connection; - const targetConnection = connection.targetConnection; - return ASTNode.createConnectionNode(targetConnection!); - } - } - - return null; - } - - /** - * Find the element to the left of the current element in the AST. - * - * @returns An AST node that wraps the previous field, connection, workspace - * or block. Or null if no node exists to the left. null. - */ - prev(): ASTNode | null { - switch (this.type) { - case ASTNode.types.STACK: - return this.navigateBetweenStacks(false); - - case ASTNode.types.OUTPUT: - return null; - - case ASTNode.types.FIELD: - return this.findPrevForField(); - - case ASTNode.types.INPUT: - return this.findPrevForInput(); - - case ASTNode.types.BLOCK: { - const block = this.location as Block; - const topConnection = getParentConnection(block); - if (!topConnection) return null; - return ASTNode.createConnectionNode(topConnection); - } - case ASTNode.types.PREVIOUS: { - const connection = this.location as Connection; - const targetConnection = connection.targetConnection; - if (targetConnection && !targetConnection.getParentInput()) { - return ASTNode.createConnectionNode(targetConnection); - } - break; - } - case ASTNode.types.NEXT: { - const connection = this.location as Connection; - return ASTNode.createBlockNode(connection.getSourceBlock()); - } - case ASTNode.types.BUTTON: - return this.navigateFlyoutContents(false); - } - - return null; - } - - /** - * Find the next element that is one position above and all the way to the - * left of the current location. - * - * @returns An AST node that wraps the next field, connection, workspace or - * block. Or null if we are at the workspace level. - */ - out(): ASTNode | null { - switch (this.type) { - case ASTNode.types.STACK: { - const block = this.location as Block; - const blockPos = block.getRelativeToSurfaceXY(); - // TODO: Make sure this is in the bounds of the workspace. - const wsCoordinate = new Coordinate( - blockPos.x, - blockPos.y + ASTNode.DEFAULT_OFFSET_Y, - ); - return ASTNode.createWorkspaceNode(block.workspace, wsCoordinate); - } - case ASTNode.types.OUTPUT: { - const connection = this.location as Connection; - const target = connection.targetConnection; - if (target) { - return ASTNode.createConnectionNode(target); - } - return ASTNode.createStackNode(connection.getSourceBlock()); - } - case ASTNode.types.FIELD: { - const field = this.location as Field; - const block = field.getSourceBlock(); - if (!block) { - throw new Error( - 'The current AST location is not associated with a block', - ); - } - return ASTNode.createBlockNode(block); - } - case ASTNode.types.INPUT: { - const connection = this.location as Connection; - return ASTNode.createBlockNode(connection.getSourceBlock()); - } - case ASTNode.types.BLOCK: { - const block = this.location as Block; - return this.getOutAstNodeForBlock(block); - } - case ASTNode.types.PREVIOUS: { - const connection = this.location as Connection; - return this.getOutAstNodeForBlock(connection.getSourceBlock()); - } - case ASTNode.types.NEXT: { - const connection = this.location as Connection; - return this.getOutAstNodeForBlock(connection.getSourceBlock()); - } - } - - return null; - } - - /** - * Whether an AST node of the given type points to a connection. - * - * @param type The type to check. One of ASTNode.types. - * @returns True if a node of the given type points to a connection. - */ - private static isConnectionType(type: string): boolean { - switch (type) { - case ASTNode.types.PREVIOUS: - case ASTNode.types.NEXT: - case ASTNode.types.INPUT: - case ASTNode.types.OUTPUT: - return true; - } - return false; - } - - /** - * Create an AST node pointing to a field. - * - * @param field The location of the AST node. - * @returns An AST node pointing to a field. - */ - static createFieldNode(field: Field): ASTNode | null { - if (!field) { - return null; - } - return new ASTNode(ASTNode.types.FIELD, field); - } - - /** - * Creates an AST node pointing to a connection. If the connection has a - * parent input then create an AST node of type input that will hold the - * connection. - * - * @param connection This is the connection the node will point to. - * @returns An AST node pointing to a connection. - */ - static createConnectionNode(connection: Connection): ASTNode | null { - if (!connection) { - return null; - } - const type = connection.type; - if (type === ConnectionType.INPUT_VALUE) { - // AnyDuringMigration because: Argument of type 'Input | null' is not - // assignable to parameter of type 'Input'. - return ASTNode.createInputNode( - connection.getParentInput() as AnyDuringMigration, - ); - } else if ( - type === ConnectionType.NEXT_STATEMENT && - connection.getParentInput() - ) { - // AnyDuringMigration because: Argument of type 'Input | null' is not - // assignable to parameter of type 'Input'. - return ASTNode.createInputNode( - connection.getParentInput() as AnyDuringMigration, - ); - } else if (type === ConnectionType.NEXT_STATEMENT) { - return new ASTNode(ASTNode.types.NEXT, connection); - } else if (type === ConnectionType.OUTPUT_VALUE) { - return new ASTNode(ASTNode.types.OUTPUT, connection); - } else if (type === ConnectionType.PREVIOUS_STATEMENT) { - return new ASTNode(ASTNode.types.PREVIOUS, connection); - } - return null; - } - - /** - * Creates an AST node pointing to an input. Stores the input connection as - * the location. - * - * @param input The input used to create an AST node. - * @returns An AST node pointing to a input. - */ - static createInputNode(input: Input): ASTNode | null { - if (!input || !input.connection) { - return null; - } - return new ASTNode(ASTNode.types.INPUT, input.connection); - } - - /** - * Creates an AST node pointing to a block. - * - * @param block The block used to create an AST node. - * @returns An AST node pointing to a block. - */ - static createBlockNode(block: Block): ASTNode | null { - if (!block) { - return null; - } - return new ASTNode(ASTNode.types.BLOCK, block); - } - - /** - * Create an AST node of type stack. A stack, represented by its top block, is - * the set of all blocks connected to a top block, including the top - * block. - * - * @param topBlock A top block has no parent and can be found in the list - * returned by workspace.getTopBlocks(). - * @returns An AST node of type stack that points to the top block on the - * stack. - */ - static createStackNode(topBlock: Block): ASTNode | null { - if (!topBlock) { - return null; - } - return new ASTNode(ASTNode.types.STACK, topBlock); - } - - /** - * Create an AST node of type button. A button in this case refers - * specifically to a button in a flyout. - * - * @param button A top block has no parent and can be found in the list - * returned by workspace.getTopBlocks(). - * @returns An AST node of type stack that points to the top block on the - * stack. - */ - static createButtonNode(button: FlyoutButton): ASTNode | null { - if (!button) { - return null; - } - return new ASTNode(ASTNode.types.BUTTON, button); - } - - /** - * Creates an AST node pointing to a workspace. - * - * @param workspace The workspace that we are on. - * @param wsCoordinate The position on the workspace for this node. - * @returns An AST node pointing to a workspace and a position on the - * workspace. - */ - static createWorkspaceNode( - workspace: Workspace | null, - wsCoordinate: Coordinate | null, - ): ASTNode | null { - if (!wsCoordinate || !workspace) { - return null; - } - const params = {wsCoordinate}; - return new ASTNode(ASTNode.types.WORKSPACE, workspace, params); - } - - /** - * Creates an AST node for the top position on a block. - * This is either an output connection, previous connection, or block. - * - * @param block The block to find the top most AST node on. - * @returns The AST node holding the top most position on the block. - */ - static createTopNode(block: Block): ASTNode | null { - let astNode; - const topConnection = getParentConnection(block); - if (topConnection) { - astNode = ASTNode.createConnectionNode(topConnection); - } else { - astNode = ASTNode.createBlockNode(block); - } - return astNode; - } -} - -export namespace ASTNode { - export interface Params { - wsCoordinate: Coordinate; - } - - export enum types { - FIELD = 'field', - BLOCK = 'block', - INPUT = 'input', - OUTPUT = 'output', - NEXT = 'next', - PREVIOUS = 'previous', - STACK = 'stack', - WORKSPACE = 'workspace', - BUTTON = 'button', - } -} - -export type Params = ASTNode.Params; -// No need to export ASTNode.types from the module at this time because (1) it -// wasn't automatically converted by the automatic migration script, (2) the -// name doesn't follow the styleguide. - -/** - * Gets the parent connection on a block. - * This is either an output connection, previous connection or undefined. - * If both connections exist return the one that is actually connected - * to another block. - * - * @param block The block to find the parent connection on. - * @returns The connection connecting to the parent of the block. - */ -function getParentConnection(block: Block): Connection | null { - let topConnection = block.outputConnection; - if ( - !topConnection || - (block.previousConnection && block.previousConnection.isConnected()) - ) { - topConnection = block.previousConnection; - } - return topConnection; -} diff --git a/core/keyboard_nav/basic_cursor.ts b/core/keyboard_nav/basic_cursor.ts deleted file mode 100644 index 7526141529e..00000000000 --- a/core/keyboard_nav/basic_cursor.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing a basic cursor. - * Used to demo switching between different cursors. - * - * @class - */ -// Former goog.module ID: Blockly.BasicCursor - -import * as registry from '../registry.js'; -import {ASTNode} from './ast_node.js'; -import {Cursor} from './cursor.js'; - -/** - * Class for a basic cursor. - * This will allow the user to get to all nodes in the AST by hitting next or - * previous. - */ -export class BasicCursor extends Cursor { - /** Name used for registering a basic cursor. */ - static readonly registrationName = 'basicCursor'; - - constructor() { - super(); - } - - /** - * Find the next node in the pre order traversal. - * - * @returns The next node, or null if the current node is not set or there is - * no next value. - */ - override next(): ASTNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getNextNode_(curNode, this.validNode_); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * For a basic cursor we only have the ability to go next and previous, so - * in will also allow the user to get to the next node in the pre order - * traversal. - * - * @returns The next node, or null if the current node is not set or there is - * no next value. - */ - override in(): ASTNode | null { - return this.next(); - } - - /** - * Find the previous node in the pre order traversal. - * - * @returns The previous node, or null if the current node is not set or there - * is no previous value. - */ - override prev(): ASTNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getPreviousNode_(curNode, this.validNode_); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * For a basic cursor we only have the ability to go next and previous, so - * out will allow the user to get to the previous node in the pre order - * traversal. - * - * @returns The previous node, or null if the current node is not set or there - * is no previous value. - */ - override out(): ASTNode | null { - return this.prev(); - } - - /** - * Uses pre order traversal to navigate the Blockly AST. This will allow - * a user to easily navigate the entire Blockly AST without having to go in - * and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @returns The next node in the traversal. - */ - protected getNextNode_( - node: ASTNode | null, - isValid: (p1: ASTNode | null) => boolean, - ): ASTNode | null { - if (!node) { - return null; - } - const newNode = node.in() || node.next(); - if (isValid(newNode)) { - return newNode; - } else if (newNode) { - return this.getNextNode_(newNode, isValid); - } - const siblingOrParent = this.findSiblingOrParent(node.out()); - if (isValid(siblingOrParent)) { - return siblingOrParent; - } else if (siblingOrParent) { - return this.getNextNode_(siblingOrParent, isValid); - } - return null; - } - - /** - * Reverses the pre order traversal in order to find the previous node. This - * will allow a user to easily navigate the entire Blockly AST without having - * to go in and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @returns The previous node in the traversal or null if no previous node - * exists. - */ - protected getPreviousNode_( - node: ASTNode | null, - isValid: (p1: ASTNode | null) => boolean, - ): ASTNode | null { - if (!node) { - return null; - } - let newNode: ASTNode | null = node.prev(); - - if (newNode) { - newNode = this.getRightMostChild(newNode); - } else { - newNode = node.out(); - } - if (isValid(newNode)) { - return newNode; - } else if (newNode) { - return this.getPreviousNode_(newNode, isValid); - } - return null; - } - - /** - * Decides what nodes to traverse and which ones to skip. Currently, it - * skips output, stack and workspace nodes. - * - * @param node The AST node to check whether it is valid. - * @returns True if the node should be visited, false otherwise. - */ - protected validNode_(node: ASTNode | null): boolean { - let isValid = false; - const type = node && node.getType(); - if ( - type === ASTNode.types.OUTPUT || - type === ASTNode.types.INPUT || - type === ASTNode.types.FIELD || - type === ASTNode.types.NEXT || - type === ASTNode.types.PREVIOUS || - type === ASTNode.types.WORKSPACE - ) { - isValid = true; - } - return isValid; - } - - /** - * From the given node find either the next valid sibling or parent. - * - * @param node The current position in the AST. - * @returns The parent AST node or null if there are no valid parents. - */ - private findSiblingOrParent(node: ASTNode | null): ASTNode | null { - if (!node) { - return null; - } - const nextNode = node.next(); - if (nextNode) { - return nextNode; - } - return this.findSiblingOrParent(node.out()); - } - - /** - * Get the right most child of a node. - * - * @param node The node to find the right most child of. - * @returns The right most child of the given node, or the node if no child - * exists. - */ - private getRightMostChild(node: ASTNode | null): ASTNode | null { - if (!node!.in()) { - return node; - } - let newNode = node!.in(); - while (newNode && newNode.next()) { - newNode = newNode.next(); - } - return this.getRightMostChild(newNode); - } -} - -registry.register( - registry.Type.CURSOR, - BasicCursor.registrationName, - BasicCursor, -); diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts new file mode 100644 index 00000000000..74f970d9961 --- /dev/null +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../block_svg.js'; +import type {Field} from '../field.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Set of rules controlling keyboard navigation from a block. + */ +export class BlockNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given block. + * + * @param current The block to return the first child of. + * @returns The first field or input of the given block, if any. + */ + getFirstChild(current: BlockSvg): IFocusableNode | null { + for (const input of current.inputList) { + for (const field of input.fieldRow) { + return field; + } + if (input.connection?.targetBlock()) + return input.connection.targetBlock() as BlockSvg; + } + + return null; + } + + /** + * Returns the parent of the given block. + * + * @param current The block to return the parent of. + * @returns The top block of the given block's stack, or the connection to + * which it is attached. + */ + getParent(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + const surroundParent = current.getSurroundParent(); + if (surroundParent) return surroundParent; + } else if (current.outputConnection?.targetBlock()) { + return current.outputConnection.targetBlock(); + } + + return current.workspace; + } + + /** + * Returns the next peer node of the given block. + * + * @param current The block to find the following element of. + * @returns The first block of the next stack if the given block is a terminal + * block, or its next connection. + */ + getNextSibling(current: BlockSvg): IFocusableNode | null { + if (current.nextConnection?.targetBlock()) { + return current.nextConnection?.targetBlock(); + } + + const parent = this.getParent(current); + let navigatingCrossStacks = false; + let siblings: (BlockSvg | Field)[] = []; + if (parent instanceof BlockSvg) { + for (let i = 0, input; (input = parent.inputList[i]); i++) { + if (input.connection) { + siblings.push(...input.fieldRow); + const child = input.connection.targetBlock(); + if (child) { + siblings.push(child as BlockSvg); + } + } + } + } else if (parent instanceof WorkspaceSvg) { + siblings = parent.getTopBlocks(true); + navigatingCrossStacks = true; + } else { + return null; + } + + const currentIndex = siblings.indexOf( + navigatingCrossStacks ? current.getRootBlock() : current, + ); + if (currentIndex >= 0 && currentIndex < siblings.length - 1) { + return siblings[currentIndex + 1]; + } else if (currentIndex === siblings.length - 1 && navigatingCrossStacks) { + return siblings[0]; + } + + return null; + } + + /** + * Returns the previous peer node of the given block. + * + * @param current The block to find the preceding element of. + * @returns The block's previous/output connection, or the last + * connection/block of the previous block stack if it is a root block. + */ + getPreviousSibling(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + return current.previousConnection?.targetBlock(); + } + + const parent = this.getParent(current); + let navigatingCrossStacks = false; + let siblings: (BlockSvg | Field)[] = []; + if (parent instanceof BlockSvg) { + for (let i = 0, input; (input = parent.inputList[i]); i++) { + if (input.connection) { + siblings.push(...input.fieldRow); + const child = input.connection.targetBlock(); + if (child) { + siblings.push(child as BlockSvg); + } + } + } + } else if (parent instanceof WorkspaceSvg) { + siblings = parent.getTopBlocks(true); + navigatingCrossStacks = true; + } else { + return null; + } + + const currentIndex = siblings.indexOf(current); + let result: IFocusableNode | null = null; + if (currentIndex >= 1) { + result = siblings[currentIndex - 1]; + } else if (currentIndex === 0 && navigatingCrossStacks) { + result = siblings[siblings.length - 1]; + } + + // If navigating to a previous stack, our previous sibling is the last + // block in it. + if (navigatingCrossStacks && result instanceof BlockSvg) { + return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + } + + return result; + } + + /** + * Returns whether or not the given block can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given block can be focused. + */ + isNavigable(current: BlockSvg): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a BlockSvg. + */ + isApplicable(current: any): current is BlockSvg { + return current instanceof BlockSvg; + } +} diff --git a/core/keyboard_nav/connection_navigation_policy.ts b/core/keyboard_nav/connection_navigation_policy.ts new file mode 100644 index 00000000000..9c3eafc56b0 --- /dev/null +++ b/core/keyboard_nav/connection_navigation_policy.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../block_svg.js'; +import {ConnectionType} from '../connection_type.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../rendered_connection.js'; + +/** + * Set of rules controlling keyboard navigation from a connection. + */ +export class ConnectionNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given connection. + * + * @param current The connection to return the first child of. + * @returns The connection's first child element, or null if not none. + */ + getFirstChild(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return current.targetConnection; + } + + return null; + } + + /** + * Returns the parent of the given connection. + * + * @param current The connection to return the parent of. + * @returns The given connection's parent connection or block. + */ + getParent(current: RenderedConnection): IFocusableNode | null { + if (current.type === ConnectionType.OUTPUT_VALUE) { + return current.targetConnection ?? current.getSourceBlock(); + } else if (current.getParentInput()) { + return current.getSourceBlock(); + } + + const topBlock = current.getSourceBlock().getTopStackBlock(); + return ( + (this.getParentConnection(topBlock)?.targetConnection?.getParentInput() + ?.connection as RenderedConnection) ?? topBlock + ); + } + + /** + * Returns the next element following the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block following this connection. + */ + getNextSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + const parentInput = current.getParentInput(); + const block = parentInput?.getSourceBlock(); + if (!block || !parentInput) return null; + + const curIdx = block.inputList.indexOf(parentInput); + for (let i = curIdx + 1; i < block.inputList.length; i++) { + const input = block.inputList[i]; + const fieldRow = input.fieldRow; + if (fieldRow.length) return fieldRow[0]; + if (input.connection) return input.connection as RenderedConnection; + } + + return null; + } else if (current.type === ConnectionType.NEXT_STATEMENT) { + const nextBlock = current.targetConnection; + // If this connection is the last one in the stack, our next sibling is + // the next block stack. + const sourceBlock = current.getSourceBlock(); + if ( + !nextBlock && + sourceBlock.getRootBlock().lastConnectionInStack(false) === current + ) { + const topBlocks = sourceBlock.workspace.getTopBlocks(true); + let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1; + if (targetIndex >= topBlocks.length) { + targetIndex = 0; + } + const nextBlock = topBlocks[targetIndex]; + return this.getParentConnection(nextBlock) ?? nextBlock; + } + + return nextBlock; + } + + return current.getSourceBlock(); + } + + /** + * Returns the element preceding the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block preceding this connection. + */ + getPreviousSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + const parentInput = current.getParentInput(); + const block = parentInput?.getSourceBlock(); + if (!block || !parentInput) return null; + + const curIdx = block.inputList.indexOf(parentInput); + for (let i = curIdx; i >= 0; i--) { + const input = block.inputList[i]; + if (input.connection && input !== parentInput) { + return input.connection as RenderedConnection; + } + const fieldRow = input.fieldRow; + if (fieldRow.length) return fieldRow[fieldRow.length - 1]; + } + return null; + } else if ( + current.type === ConnectionType.PREVIOUS_STATEMENT || + current.type === ConnectionType.OUTPUT_VALUE + ) { + const previousConnection = + current.targetConnection && !current.targetConnection.getParentInput() + ? current.targetConnection + : null; + + // If this connection is a disconnected previous/output connection, our + // previous sibling is the previous block stack's last connection/block. + const sourceBlock = current.getSourceBlock(); + if ( + !previousConnection && + this.getParentConnection(sourceBlock.getRootBlock()) === current + ) { + const topBlocks = sourceBlock.workspace.getTopBlocks(true); + let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1; + if (targetIndex < 0) { + targetIndex = topBlocks.length - 1; + } + const previousRootBlock = topBlocks[targetIndex]; + return ( + previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock + ); + } + + return previousConnection; + } else if (current.type === ConnectionType.NEXT_STATEMENT) { + return current.getSourceBlock(); + } + return null; + } + + /** + * Gets the parent connection on a block. + * This is either an output connection, previous connection or undefined. + * If both connections exist return the one that is actually connected + * to another block. + * + * @param block The block to find the parent connection on. + * @returns The connection connecting to the parent of the block. + */ + protected getParentConnection(block: BlockSvg) { + if (!block.outputConnection || block.previousConnection?.isConnected()) { + return block.previousConnection; + } + return block.outputConnection; + } + + /** + * Returns whether or not the given connection can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given connection can be focused. + */ + isNavigable(current: RenderedConnection): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a RenderedConnection. + */ + isApplicable(current: any): current is RenderedConnection { + return current instanceof RenderedConnection; + } +} diff --git a/core/keyboard_nav/cursor.ts b/core/keyboard_nav/cursor.ts deleted file mode 100644 index 92279da562d..00000000000 --- a/core/keyboard_nav/cursor.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing a cursor. - * Used primarily for keyboard navigation. - * - * @class - */ -// Former goog.module ID: Blockly.Cursor - -import * as registry from '../registry.js'; -import {ASTNode} from './ast_node.js'; -import {Marker} from './marker.js'; - -/** - * Class for a cursor. - * A cursor controls how a user navigates the Blockly AST. - */ -export class Cursor extends Marker { - override type = 'cursor'; - - constructor() { - super(); - } - - /** - * Find the next connection, field, or block. - * - * @returns The next element, or null if the current node is not set or there - * is no next value. - */ - next(): ASTNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - let newNode = curNode.next(); - while ( - newNode && - newNode.next() && - (newNode.getType() === ASTNode.types.NEXT || - newNode.getType() === ASTNode.types.BLOCK) - ) { - newNode = newNode.next(); - } - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Find the in connection or field. - * - * @returns The in element, or null if the current node is not set or there is - * no in value. - */ - in(): ASTNode | null { - let curNode: ASTNode | null = this.getCurNode(); - if (!curNode) { - return null; - } - // If we are on a previous or output connection, go to the block level - // before performing next operation. - if ( - curNode.getType() === ASTNode.types.PREVIOUS || - curNode.getType() === ASTNode.types.OUTPUT - ) { - curNode = curNode.next(); - } - const newNode = curNode?.in() ?? null; - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Find the previous connection, field, or block. - * - * @returns The previous element, or null if the current node is not set or - * there is no previous value. - */ - prev(): ASTNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - let newNode = curNode.prev(); - - while ( - newNode && - newNode.prev() && - (newNode.getType() === ASTNode.types.NEXT || - newNode.getType() === ASTNode.types.BLOCK) - ) { - newNode = newNode.prev(); - } - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Find the out connection, field, or block. - * - * @returns The out element, or null if the current node is not set or there - * is no out value. - */ - out(): ASTNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - let newNode = curNode.out(); - - if (newNode && newNode.getType() === ASTNode.types.BLOCK) { - newNode = newNode.prev() || newNode; - } - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } -} - -registry.register(registry.Type.CURSOR, registry.DEFAULT, Cursor); diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts new file mode 100644 index 00000000000..3b88dc9248b --- /dev/null +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../block_svg.js'; +import {Field} from '../field.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a field. + */ +export class FieldNavigationPolicy implements INavigationPolicy> { + /** + * Returns null since fields do not have children. + * + * @param _current The field to navigate from. + * @returns Null. + */ + getFirstChild(_current: Field): IFocusableNode | null { + return null; + } + + /** + * Returns the parent block of the given field. + * + * @param current The field to navigate from. + * @returns The given field's parent block. + */ + getParent(current: Field): IFocusableNode | null { + return current.getSourceBlock() as BlockSvg; + } + + /** + * Returns the next field or input following the given field. + * + * @param current The field to navigate from. + * @returns The next field or input in the given field's block. + */ + getNextSibling(current: Field): IFocusableNode | null { + const input = current.getParentInput(); + const block = current.getSourceBlock(); + if (!block) return null; + + const curIdx = block.inputList.indexOf(input); + let fieldIdx = input.fieldRow.indexOf(current) + 1; + for (let i = curIdx; i < block.inputList.length; i++) { + const newInput = block.inputList[i]; + const fieldRow = newInput.fieldRow; + if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx]; + fieldIdx = 0; + if (newInput.connection?.targetBlock()) { + return newInput.connection.targetBlock() as BlockSvg; + } + } + return null; + } + + /** + * Returns the field or input preceding the given field. + * + * @param current The field to navigate from. + * @returns The preceding field or input in the given field's block. + */ + getPreviousSibling(current: Field): IFocusableNode | null { + const parentInput = current.getParentInput(); + const block = current.getSourceBlock(); + if (!block) return null; + + const curIdx = block.inputList.indexOf(parentInput); + let fieldIdx = parentInput.fieldRow.indexOf(current) - 1; + for (let i = curIdx; i >= 0; i--) { + const input = block.inputList[i]; + if (input.connection?.targetBlock() && input !== parentInput) { + return input.connection.targetBlock() as BlockSvg; + } + const fieldRow = input.fieldRow; + if (fieldIdx > -1) return fieldRow[fieldIdx]; + + // Reset the fieldIdx to the length of the field row of the previous + // input. + if (i - 1 >= 0) { + fieldIdx = block.inputList[i - 1].fieldRow.length - 1; + } + } + return null; + } + + /** + * Returns whether or not the given field can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given field can be focused and navigated to. + */ + isNavigable(current: Field): boolean { + return ( + current.canBeFocused() && + (current.isClickable() || current.isCurrentlyEditable()) && + !( + current.getSourceBlock()?.isSimpleReporter() && + current.isFullBlockField() + ) && + current.getParentInput().isVisible() + ); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a Field. + */ + isApplicable(current: any): current is Field { + return current instanceof Field; + } +} diff --git a/core/keyboard_nav/flyout_button_navigation_policy.ts b/core/keyboard_nav/flyout_button_navigation_policy.ts new file mode 100644 index 00000000000..6c39c3061e7 --- /dev/null +++ b/core/keyboard_nav/flyout_button_navigation_policy.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutButton} from '../flyout_button.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a flyout button. + */ +export class FlyoutButtonNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns null since flyout buttons have no children. + * + * @param _current The FlyoutButton instance to navigate from. + * @returns Null. + */ + getFirstChild(_current: FlyoutButton): IFocusableNode | null { + return null; + } + + /** + * Returns the parent workspace of the given flyout button. + * + * @param current The FlyoutButton instance to navigate from. + * @returns The given flyout button's parent workspace. + */ + getParent(current: FlyoutButton): IFocusableNode | null { + return current.getWorkspace(); + } + + /** + * Returns null since inter-item navigation is done by FlyoutNavigationPolicy. + * + * @param _current The FlyoutButton instance to navigate from. + * @returns Null. + */ + getNextSibling(_current: FlyoutButton): IFocusableNode | null { + return null; + } + + /** + * Returns null since inter-item navigation is done by FlyoutNavigationPolicy. + * + * @param _current The FlyoutButton instance to navigate from. + * @returns Null. + */ + getPreviousSibling(_current: FlyoutButton): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given flyout button can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given flyout button can be focused. + */ + isNavigable(current: FlyoutButton): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a FlyoutButton. + */ + isApplicable(current: any): current is FlyoutButton { + return current instanceof FlyoutButton; + } +} diff --git a/core/keyboard_nav/flyout_navigation_policy.ts b/core/keyboard_nav/flyout_navigation_policy.ts new file mode 100644 index 00000000000..6552c27b499 --- /dev/null +++ b/core/keyboard_nav/flyout_navigation_policy.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFlyout} from '../interfaces/i_flyout.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Generic navigation policy that navigates between items in the flyout. + */ +export class FlyoutNavigationPolicy implements INavigationPolicy { + /** + * Creates a new FlyoutNavigationPolicy instance. + * + * @param policy The policy to defer to for parents/children. + * @param flyout The flyout this policy will control navigation in. + */ + constructor( + private policy: INavigationPolicy, + private flyout: IFlyout, + ) {} + + /** + * Returns null to prevent navigating into flyout items. + * + * @param _current The flyout item to navigate from. + * @returns Null to prevent navigating into flyout items. + */ + getFirstChild(_current: T): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given flyout item. + * + * @param current The flyout item to navigate from. + * @returns The parent of the given flyout item. + */ + getParent(current: T): IFocusableNode | null { + return this.policy.getParent(current); + } + + /** + * Returns the next item in the flyout relative to the given item. + * + * @param current The flyout item to navigate from. + * @returns The flyout item following the given one. + */ + getNextSibling(current: T): IFocusableNode | null { + const flyoutContents = this.flyout.getContents(); + if (!flyoutContents) return null; + + let index = flyoutContents.findIndex( + (flyoutItem) => flyoutItem.getElement() === current, + ); + + if (index === -1) return null; + index++; + if (index >= flyoutContents.length) { + index = 0; + } + + return flyoutContents[index].getElement(); + } + + /** + * Returns the previous item in the flyout relative to the given item. + * + * @param current The flyout item to navigate from. + * @returns The flyout item preceding the given one. + */ + getPreviousSibling(current: T): IFocusableNode | null { + const flyoutContents = this.flyout.getContents(); + if (!flyoutContents) return null; + + let index = flyoutContents.findIndex( + (flyoutItem) => flyoutItem.getElement() === current, + ); + + if (index === -1) return null; + index--; + if (index < 0) { + index = flyoutContents.length - 1; + } + + return flyoutContents[index].getElement(); + } + + /** + * Returns whether or not the given flyout item can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given flyout item can be focused. + */ + isNavigable(current: T): boolean { + return this.policy.isNavigable(current); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a BlockSvg. + */ + isApplicable(current: any): current is T { + return this.policy.isApplicable(current); + } +} diff --git a/core/keyboard_nav/flyout_separator_navigation_policy.ts b/core/keyboard_nav/flyout_separator_navigation_policy.ts new file mode 100644 index 00000000000..eb7ca4eb783 --- /dev/null +++ b/core/keyboard_nav/flyout_separator_navigation_policy.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutSeparator} from '../flyout_separator.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a flyout separator. + * This is a no-op placeholder, since flyout separators can't be navigated to. + */ +export class FlyoutSeparatorNavigationPolicy + implements INavigationPolicy +{ + getFirstChild(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + getParent(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + getNextSibling(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + getPreviousSibling(_current: FlyoutSeparator): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given flyout separator can be navigated to. + * + * @param _current The instance to check for navigability. + * @returns False. + */ + isNavigable(_current: FlyoutSeparator): boolean { + return false; + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a FlyoutSeparator. + */ + isApplicable(current: any): current is FlyoutSeparator { + return current instanceof FlyoutSeparator; + } +} diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts new file mode 100644 index 00000000000..9d83f6554d3 --- /dev/null +++ b/core/keyboard_nav/line_cursor.ts @@ -0,0 +1,432 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview The class representing a line cursor. + * A line cursor tries to traverse the blocks and connections on a block as if + * they were lines of code in a text editor. Previous and next traverse previous + * connections, next connections and blocks, while in and out traverse input + * connections and fields. + * @author aschmiedt@google.com (Abby Schmiedt) + */ + +import {BlockSvg} from '../block_svg.js'; +import {getFocusManager} from '../focus_manager.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; +import * as registry from '../registry.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {Marker} from './marker.js'; + +/** + * Class for a line cursor. + */ +export class LineCursor extends Marker { + override type = 'cursor'; + + /** Locations to try moving the cursor to after a deletion. */ + private potentialNodes: IFocusableNode[] | null = null; + + /** + * @param workspace The workspace this cursor belongs to. + */ + constructor(protected readonly workspace: WorkspaceSvg) { + super(); + } + + /** + * Moves the cursor to the next previous connection, next connection or block + * in the pre order traversal. Finds the next node in the pre order traversal. + * + * @returns The next node, or null if the current node is + * not set or there is no next value. + */ + next(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getNextNode( + curNode, + (candidate: IFocusableNode | null) => { + return ( + candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock() + ); + }, + true, + ); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Moves the cursor to the next input connection or field + * in the pre order traversal. + * + * @returns The next node, or null if the current node is + * not set or there is no next value. + */ + in(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + + const newNode = this.getNextNode(curNode, () => true, true); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + /** + * Moves the cursor to the previous next connection or previous connection in + * the pre order traversal. + * + * @returns The previous node, or null if the current node + * is not set or there is no previous value. + */ + prev(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getPreviousNode( + curNode, + (candidate: IFocusableNode | null) => { + return ( + candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock() + ); + }, + true, + ); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Moves the cursor to the previous input connection or field in the pre order + * traversal. + * + * @returns The previous node, or null if the current node + * is not set or there is no previous value. + */ + out(): IFocusableNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + + const newNode = this.getPreviousNode(curNode, () => true, true); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Returns true iff the node to which we would navigate if in() were + * called is the same as the node to which we would navigate if next() were + * called - in effect, if the LineCursor is at the end of the 'current + * line' of the program. + */ + atEndOfLine(): boolean { + const curNode = this.getCurNode(); + if (!curNode) return false; + const inNode = this.getNextNode(curNode, () => true, true); + const nextNode = this.getNextNode( + curNode, + (candidate: IFocusableNode | null) => { + return ( + candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock() + ); + }, + true, + ); + + return inNode === nextNode; + } + + /** + * Uses pre order traversal to navigate the Blockly AST. This will allow + * a user to easily navigate the entire Blockly AST without having to go in + * and out levels on the tree. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param visitedNodes A set of previously visited nodes used to avoid cycles. + * @returns The next node in the traversal. + */ + private getNextNodeImpl( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || visitedNodes.has(node)) return null; + let newNode = + this.workspace.getNavigator().getFirstChild(node) || + this.workspace.getNavigator().getNextSibling(node); + + let target = node; + while (target && !newNode) { + const parent = this.workspace.getNavigator().getParent(target); + if (!parent) break; + newNode = this.workspace.getNavigator().getNextSibling(parent); + target = parent; + } + + if (isValid(newNode)) return newNode; + if (newNode) { + visitedNodes.add(node); + return this.getNextNodeImpl(newNode, isValid, visitedNodes); + } + return null; + } + + /** + * Get the next node in the AST, optionally allowing for loopback. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param loop Whether to loop around to the beginning of the workspace if no + * valid node was found. + * @returns The next node in the traversal. + */ + getNextNode( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + loop: boolean, + ): IFocusableNode | null { + if (!node || (!loop && this.getLastNode() === node)) return null; + + return this.getNextNodeImpl(node, isValid); + } + + /** + * Reverses the pre order traversal in order to find the previous node. This + * will allow a user to easily navigate the entire Blockly AST without having + * to go in and out levels on the tree. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param visitedNodes A set of previously visited nodes used to avoid cycles. + * @returns The previous node in the traversal or null if no previous node + * exists. + */ + private getPreviousNodeImpl( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || visitedNodes.has(node)) return null; + + const newNode = + this.getRightMostChild( + this.workspace.getNavigator().getPreviousSibling(node), + node, + ) || this.workspace.getNavigator().getParent(node); + + if (isValid(newNode)) return newNode; + if (newNode) { + visitedNodes.add(node); + return this.getPreviousNodeImpl(newNode, isValid, visitedNodes); + } + return null; + } + + /** + * Get the previous node in the AST, optionally allowing for loopback. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param loop Whether to loop around to the end of the workspace if no valid + * node was found. + * @returns The previous node in the traversal or null if no previous node + * exists. + */ + getPreviousNode( + node: IFocusableNode | null, + isValid: (p1: IFocusableNode | null) => boolean, + loop: boolean, + ): IFocusableNode | null { + if (!node || (!loop && this.getFirstNode() === node)) return null; + + return this.getPreviousNodeImpl(node, isValid); + } + + /** + * Get the right most child of a node. + * + * @param node The node to find the right most child of. + * @returns The right most child of the given node, or the node if no child + * exists. + */ + private getRightMostChild( + node: IFocusableNode | null, + stopIfFound: IFocusableNode, + ): IFocusableNode | null { + if (!node) return node; + let newNode = this.workspace.getNavigator().getFirstChild(node); + if (!newNode || newNode === stopIfFound) return node; + for ( + let nextNode: IFocusableNode | null = newNode; + nextNode; + nextNode = this.workspace.getNavigator().getNextSibling(newNode) + ) { + if (nextNode === stopIfFound) break; + newNode = nextNode; + } + return this.getRightMostChild(newNode, stopIfFound); + } + + /** + * Prepare for the deletion of a block by making a list of nodes we + * could move the cursor to afterwards and save it to + * this.potentialNodes. + * + * After the deletion has occurred, call postDelete to move it to + * the first valid node on that list. + * + * The locations to try (in order of preference) are: + * + * - The current location. + * - The connection to which the deleted block is attached. + * - The block connected to the next connection of the deleted block. + * - The parent block of the deleted block. + * - A location on the workspace beneath the deleted block. + * + * N.B.: When block is deleted, all of the blocks conneccted to that + * block's inputs are also deleted, but not blocks connected to its + * next connection. + * + * @param deletedBlock The block that is being deleted. + */ + preDelete(deletedBlock: BlockSvg) { + const curNode = this.getCurNode(); + + const nodes: IFocusableNode[] = curNode ? [curNode] : []; + // The connection to which the deleted block is attached. + const parentConnection = + deletedBlock.previousConnection?.targetConnection ?? + deletedBlock.outputConnection?.targetConnection; + if (parentConnection) { + nodes.push(parentConnection); + } + // The block connected to the next connection of the deleted block. + const nextBlock = deletedBlock.getNextBlock(); + if (nextBlock) { + nodes.push(nextBlock); + } + // The parent block of the deleted block. + const parentBlock = deletedBlock.getParent(); + if (parentBlock) { + nodes.push(parentBlock); + } + // A location on the workspace beneath the deleted block. + // Move to the workspace. + nodes.push(this.workspace); + this.potentialNodes = nodes; + } + + /** + * Move the cursor to the first valid location in + * this.potentialNodes, following a block deletion. + */ + postDelete() { + const nodes = this.potentialNodes; + this.potentialNodes = null; + if (!nodes) throw new Error('must call preDelete first'); + for (const node of nodes) { + if (!this.getSourceBlockFromNode(node)?.disposed) { + this.setCurNode(node); + return; + } + } + throw new Error('no valid nodes in this.potentialNodes'); + } + + /** + * Get the current location of the cursor. + * + * Overrides normal Marker getCurNode to update the current node from the + * selected block. This typically happens via the selection listener but that + * is not called immediately when `Gesture` calls + * `Blockly.common.setSelected`. In particular the listener runs after showing + * the context menu. + * + * @returns The current field, connection, or block the cursor is on. + */ + override getCurNode(): IFocusableNode | null { + // Ensure the current node matches what's currently focused. + const focused = getFocusManager().getFocusedNode(); + const block = this.getSourceBlockFromNode(focused); + if (!block || block.workspace === this.workspace) { + // If the current focused node corresponds to a block then ensure that it + // belongs to the correct workspace for this cursor. + this.setCurNode(focused); + } + + return super.getCurNode(); + } + + /** + * Set the location of the cursor and draw it. + * + * Overrides normal Marker setCurNode logic to call + * this.drawMarker() instead of this.drawer.draw() directly. + * + * @param newNode The new location of the cursor. + */ + override setCurNode(newNode: IFocusableNode | null) { + super.setCurNode(newNode); + + if (isFocusableNode(newNode)) { + getFocusManager().focusNode(newNode); + } + + // Try to scroll cursor into view. + if (newNode instanceof BlockSvg) { + newNode.workspace.scrollBoundsIntoView( + newNode.getBoundingRectangleWithoutChildren(), + ); + } + } + + /** + * Get the first navigable node on the workspace, or null if none exist. + * + * @returns The first navigable node on the workspace, or null. + */ + getFirstNode(): IFocusableNode | null { + return this.workspace.getNavigator().getFirstChild(this.workspace); + } + + /** + * Get the last navigable node on the workspace, or null if none exist. + * + * @returns The last navigable node on the workspace, or null. + */ + getLastNode(): IFocusableNode | null { + const first = this.getFirstNode(); + return this.getPreviousNode(first, () => true, true); + } +} + +registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/core/keyboard_nav/marker.ts b/core/keyboard_nav/marker.ts index e3b438e6efe..5c2e7e9468b 100644 --- a/core/keyboard_nav/marker.ts +++ b/core/keyboard_nav/marker.ts @@ -12,8 +12,10 @@ */ // Former goog.module ID: Blockly.Marker -import type {MarkerSvg} from '../renderers/common/marker_svg.js'; -import type {ASTNode} from './ast_node.js'; +import {BlockSvg} from '../block_svg.js'; +import {Field} from '../field.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {RenderedConnection} from '../rendered_connection.js'; /** * Class for a marker. @@ -24,88 +26,58 @@ export class Marker { colour: string | null = null; /** The current location of the marker. */ - // AnyDuringMigration because: Type 'null' is not assignable to type - // 'ASTNode'. - private curNode: ASTNode = null as AnyDuringMigration; - - /** - * The object in charge of drawing the visual representation of the current - * node. - */ - // AnyDuringMigration because: Type 'null' is not assignable to type - // 'MarkerSvg'. - private drawer: MarkerSvg = null as AnyDuringMigration; + protected curNode: IFocusableNode | null = null; /** The type of the marker. */ type = 'marker'; - /** Constructs a new Marker instance. */ - constructor() {} - - /** - * Sets the object in charge of drawing the marker. - * - * @param drawer The object in charge of drawing the marker. - */ - setDrawer(drawer: MarkerSvg) { - this.drawer = drawer; - } - - /** - * Get the current drawer for the marker. - * - * @returns The object in charge of drawing the marker. - */ - getDrawer(): MarkerSvg { - return this.drawer; - } - /** * Gets the current location of the marker. * * @returns The current field, connection, or block the marker is on. */ - getCurNode(): ASTNode { + getCurNode(): IFocusableNode | null { return this.curNode; } /** * Set the location of the marker and call the update method. - * Setting isStack to true will only work if the newLocation is the top most - * output or previous connection on a stack. * - * @param newNode The new location of the marker. + * @param newNode The new location of the marker, or null to remove it. */ - setCurNode(newNode: ASTNode) { - const oldNode = this.curNode; + setCurNode(newNode: IFocusableNode | null) { this.curNode = newNode; - if (this.drawer) { - this.drawer.draw(oldNode, this.curNode); - } + } + + /** Dispose of this marker. */ + dispose() { + this.curNode = null; } /** - * Redraw the current marker. + * Returns the block that the given node is a child of. * - * @internal + * @returns The parent block of the node if any, otherwise null. */ - draw() { - if (this.drawer) { - this.drawer.draw(this.curNode, this.curNode); + getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { + if (node instanceof BlockSvg) { + return node; + } else if (node instanceof Field) { + return node.getSourceBlock() as BlockSvg; + } else if (node instanceof RenderedConnection) { + return node.getSourceBlock(); } - } - /** Hide the marker SVG. */ - hide() { - if (this.drawer) { - this.drawer.hide(); - } + return null; } - /** Dispose of this marker. */ - dispose() { - if (this.getDrawer()) { - this.getDrawer().dispose(); - } + /** + * Returns the block that this marker's current node is a child of. + * + * @returns The parent block of the marker's current node if any, otherwise + * null. + */ + getSourceBlock(): BlockSvg | null { + return this.getSourceBlockFromNode(this.getCurNode()); } } diff --git a/core/keyboard_nav/tab_navigate_cursor.ts b/core/keyboard_nav/tab_navigate_cursor.ts deleted file mode 100644 index 0392887a1fd..00000000000 --- a/core/keyboard_nav/tab_navigate_cursor.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing a cursor that is used to navigate - * between tab navigable fields. - * - * @class - */ -// Former goog.module ID: Blockly.TabNavigateCursor - -import type {Field} from '../field.js'; -import {ASTNode} from './ast_node.js'; -import {BasicCursor} from './basic_cursor.js'; - -/** - * A cursor for navigating between tab navigable fields. - */ -export class TabNavigateCursor extends BasicCursor { - /** - * Skip all nodes except for tab navigable fields. - * - * @param node The AST node to check whether it is valid. - * @returns True if the node should be visited, false otherwise. - */ - override validNode_(node: ASTNode | null): boolean { - let isValid = false; - const type = node && node.getType(); - if (node) { - const location = node.getLocation() as Field; - if ( - type === ASTNode.types.FIELD && - location && - location.isTabNavigable() && - location.isClickable() - ) { - isValid = true; - } - } - return isValid; - } -} diff --git a/core/keyboard_nav/workspace_navigation_policy.ts b/core/keyboard_nav/workspace_navigation_policy.ts new file mode 100644 index 00000000000..12a7555b43f --- /dev/null +++ b/core/keyboard_nav/workspace_navigation_policy.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Set of rules controlling keyboard navigation from a workspace. + */ +export class WorkspaceNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given workspace. + * + * @param current The workspace to return the first child of. + * @returns The top block of the first block stack, if any. + */ + getFirstChild(current: WorkspaceSvg): IFocusableNode | null { + const blocks = current.getTopBlocks(true); + return blocks.length ? blocks[0] : null; + } + + /** + * Returns the parent of the given workspace. + * + * @param _current The workspace to return the parent of. + * @returns Null. + */ + getParent(_current: WorkspaceSvg): IFocusableNode | null { + return null; + } + + /** + * Returns the next sibling of the given workspace. + * + * @param _current The workspace to return the next sibling of. + * @returns Null. + */ + getNextSibling(_current: WorkspaceSvg): IFocusableNode | null { + return null; + } + + /** + * Returns the previous sibling of the given workspace. + * + * @param _current The workspace to return the previous sibling of. + * @returns Null. + */ + getPreviousSibling(_current: WorkspaceSvg): IFocusableNode | null { + return null; + } + + /** + * Returns whether or not the given workspace can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given workspace can be focused. + */ + isNavigable(current: WorkspaceSvg): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a WorkspaceSvg. + */ + isApplicable(current: any): current is WorkspaceSvg { + return current instanceof WorkspaceSvg; + } +} diff --git a/core/label_flyout_inflater.ts b/core/label_flyout_inflater.ts new file mode 100644 index 00000000000..ffa69ae4806 --- /dev/null +++ b/core/label_flyout_inflater.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutButton} from './flyout_button.js'; +import {FlyoutItem} from './flyout_item.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import {ButtonOrLabelInfo} from './utils/toolbox.js'; +const LABEL_TYPE = 'label'; + +/** + * Class responsible for creating labels for flyouts. + */ +export class LabelFlyoutInflater implements IFlyoutInflater { + /** + * Inflates a flyout label from the given state and adds it to the flyout. + * + * @param state A JSON representation of a flyout label. + * @param flyout The flyout to create the label on. + * @returns A FlyoutButton configured as a label. + */ + load(state: object, flyout: IFlyout): FlyoutItem { + const label = new FlyoutButton( + flyout.getWorkspace(), + flyout.targetWorkspace!, + state as ButtonOrLabelInfo, + true, + ); + label.show(); + + return new FlyoutItem(label, LABEL_TYPE); + } + + /** + * Returns the amount of space that should follow this label. + * + * @param state A JSON representation of a flyout label. + * @param defaultGap The default spacing for flyout items. + * @returns The amount of space that should follow this label. + */ + gapForItem(state: object, defaultGap: number): number { + return defaultGap; + } + + /** + * Disposes of the given label. + * + * @param item The flyout label to dispose of. + */ + disposeItem(item: FlyoutItem): void { + const element = item.getElement(); + if (element instanceof FlyoutButton) { + element.dispose(); + } + } + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return LABEL_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + LABEL_TYPE, + LabelFlyoutInflater, +); diff --git a/core/layer_manager.ts b/core/layer_manager.ts index e7663b1b7ee..1d5afdd74e9 100644 --- a/core/layer_manager.ts +++ b/core/layer_manager.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {getFocusManager} from './focus_manager.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {IRenderedElement} from './interfaces/i_rendered_element.js'; import * as layerNums from './layers.js'; import {Coordinate} from './utils/coordinate.js'; @@ -99,8 +101,12 @@ export class LayerManager { * * @internal */ - moveToDragLayer(elem: IRenderedElement) { + moveToDragLayer(elem: IRenderedElement & IFocusableNode) { this.dragLayer?.appendChild(elem.getSvgRoot()); + + // Since moving the element to the drag layer will cause it to lose focus, + // ensure it regains focus (to ensure proper highlights & sent events). + getFocusManager().focusNode(elem); } /** @@ -108,8 +114,12 @@ export class LayerManager { * * @internal */ - moveOffDragLayer(elem: IRenderedElement, layerNum: number) { + moveOffDragLayer(elem: IRenderedElement & IFocusableNode, layerNum: number) { this.append(elem, layerNum); + + // Since moving the element off the drag layer will cause it to lose focus, + // ensure it regains focus (to ensure proper highlights & sent events). + getFocusManager().focusNode(elem); } /** @@ -122,7 +132,12 @@ export class LayerManager { if (!this.layers.has(layerNum)) { this.createLayer(layerNum); } - this.layers.get(layerNum)?.appendChild(elem.getSvgRoot()); + const childElem = elem.getSvgRoot(); + if (this.layers.get(layerNum)?.lastChild !== childElem) { + // Only append the child if it isn't already last (to avoid re-firing + // events like focused). + this.layers.get(layerNum)?.appendChild(childElem); + } } /** diff --git a/core/marker_manager.ts b/core/marker_manager.ts index d7035534da7..95a2d9b8bce 100644 --- a/core/marker_manager.ts +++ b/core/marker_manager.ts @@ -11,7 +11,7 @@ */ // Former goog.module ID: Blockly.MarkerManager -import type {Cursor} from './keyboard_nav/cursor.js'; +import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Marker} from './keyboard_nav/marker.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -23,17 +23,11 @@ export class MarkerManager { static readonly LOCAL_MARKER = 'local_marker_1'; /** The cursor. */ - private cursor: Cursor | null = null; - - /** The cursor's SVG element. */ - private cursorSvg: SVGElement | null = null; + private cursor: LineCursor | null = null; /** The map of markers for the workspace. */ private markers = new Map(); - /** The marker's SVG element. */ - private markerSvg: SVGElement | null = null; - /** * @param workspace The workspace for the marker manager. * @internal @@ -50,10 +44,6 @@ export class MarkerManager { if (this.markers.has(id)) { this.unregisterMarker(id); } - marker.setDrawer( - this.workspace.getRenderer().makeMarkerDrawer(this.workspace, marker), - ); - this.setMarkerSvg(marker.getDrawer().createDom()); this.markers.set(id, marker); } @@ -82,7 +72,7 @@ export class MarkerManager { * * @returns The cursor for this workspace. */ - getCursor(): Cursor | null { + getCursor(): LineCursor | null { return this.cursor; } @@ -103,70 +93,8 @@ export class MarkerManager { * * @param cursor The cursor used to move around this workspace. */ - setCursor(cursor: Cursor) { - if (this.cursor && this.cursor.getDrawer()) { - this.cursor.getDrawer().dispose(); - } + setCursor(cursor: LineCursor) { this.cursor = cursor; - if (this.cursor) { - const drawer = this.workspace - .getRenderer() - .makeMarkerDrawer(this.workspace, this.cursor); - this.cursor.setDrawer(drawer); - this.setCursorSvg(this.cursor.getDrawer().createDom()); - } - } - - /** - * Add the cursor SVG to this workspace SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the workspace - * SVG group. - * @internal - */ - setCursorSvg(cursorSvg: SVGElement | null) { - if (!cursorSvg) { - this.cursorSvg = null; - return; - } - - this.workspace.getBlockCanvas()!.appendChild(cursorSvg); - this.cursorSvg = cursorSvg; - } - - /** - * Add the marker SVG to this workspaces SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the workspace - * SVG group. - * @internal - */ - setMarkerSvg(markerSvg: SVGElement | null) { - if (!markerSvg) { - this.markerSvg = null; - return; - } - - if (this.workspace.getBlockCanvas()) { - if (this.cursorSvg) { - this.workspace - .getBlockCanvas()! - .insertBefore(markerSvg, this.cursorSvg); - } else { - this.workspace.getBlockCanvas()!.appendChild(markerSvg); - } - } - } - - /** - * Redraw the attached cursor SVG if needed. - * - * @internal - */ - updateMarkers() { - if (this.workspace.keyboardAccessibilityMode && this.cursorSvg) { - this.workspace.getCursor()!.draw(); - } } /** diff --git a/core/menu.ts b/core/menu.ts index ee54c8cf2c3..13fd0866f49 100644 --- a/core/menu.ts +++ b/core/menu.ts @@ -12,10 +12,10 @@ // Former goog.module ID: Blockly.Menu import * as browserEvents from './browser_events.js'; -import type {MenuItem} from './menuitem.js'; +import type {MenuSeparator} from './menu_separator.js'; +import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; -import * as dom from './utils/dom.js'; import type {Size} from './utils/size.js'; import * as style from './utils/style.js'; @@ -24,11 +24,9 @@ import * as style from './utils/style.js'; */ export class Menu { /** - * Array of menu items. - * (Nulls are never in the array, but typing the array as nullable prevents - * the compiler from objecting to .indexOf(null)) + * Array of menu items and separators. */ - private readonly menuItems: MenuItem[] = []; + private readonly menuItems: Array = []; /** * Coordinates of the pointerdown event that caused this menu to open. Used to @@ -70,10 +68,10 @@ export class Menu { /** * Add a new menu item to the bottom of this menu. * - * @param menuItem Menu item to append. + * @param menuItem Menu item or separator to append. * @internal */ - addChild(menuItem: MenuItem) { + addChild(menuItem: MenuItem | MenuSeparator) { this.menuItems.push(menuItem); } @@ -83,10 +81,10 @@ export class Menu { * @param container Element upon which to append this menu. * @returns The menu's root DOM element. */ + render(container: Element): HTMLDivElement { const element = document.createElement('div'); - // goog-menu is deprecated, use blocklyMenu. May 2020. - element.className = 'blocklyMenu goog-menu blocklyNonSelectable'; + element.className = 'blocklyMenu'; element.tabIndex = 0; if (this.roleName) { aria.setRole(element, this.roleName); @@ -157,7 +155,6 @@ export class Menu { const el = this.getElement(); if (el) { el.focus({preventScroll: true}); - dom.addClass(el, 'blocklyFocused'); } } @@ -166,7 +163,6 @@ export class Menu { const el = this.getElement(); if (el) { el.blur(); - dom.removeClass(el, 'blocklyFocused'); } } @@ -230,7 +226,8 @@ export class Menu { while (currentElement && currentElement !== menuElem) { if (currentElement.classList.contains('blocklyMenuItem')) { // Having found a menu item's div, locate that menu item in this menu. - for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) { + const items = this.getMenuItems(); + for (let i = 0, menuItem; (menuItem = items[i]); i++) { if (menuItem.getElement() === currentElement) { return menuItem; } @@ -261,11 +258,10 @@ export class Menu { // Bring the highlighted item into view. This has no effect if the menu is // not scrollable. const menuElement = this.getElement(); - const scrollingParent = menuElement?.parentElement; const menuItemElement = item.getElement(); - if (!scrollingParent || !menuItemElement) return; + if (!menuElement || !menuItemElement) return; - style.scrollIntoContainerView(menuItemElement, scrollingParent); + style.scrollIntoContainerView(menuItemElement, menuElement); aria.setState(menuElement, aria.State.ACTIVEDESCENDANT, item.getId()); } } @@ -316,7 +312,8 @@ export class Menu { private highlightHelper(startIndex: number, delta: number) { let index = startIndex + delta; let menuItem; - while ((menuItem = this.menuItems[index])) { + const items = this.getMenuItems(); + while ((menuItem = items[index])) { if (menuItem.isEnabled()) { this.setHighlighted(menuItem); break; @@ -381,7 +378,7 @@ export class Menu { const menuItem = this.getMenuItem(e.target as Element); if (menuItem) { - menuItem.performAction(); + menuItem.performAction(e); } } @@ -408,9 +405,7 @@ export class Menu { // Keyboard events. /** - * Attempts to handle a keyboard event, if the menu item is enabled, by - * calling - * {@link Menu#handleKeyEventInternal_}. + * Attempts to handle a keyboard event. * * @param e Key event to handle. */ @@ -435,7 +430,7 @@ export class Menu { case 'Enter': case ' ': if (highlighted) { - highlighted.performAction(); + highlighted.performAction(e); } break; @@ -479,4 +474,13 @@ export class Menu { menuSize.height = menuDom.scrollHeight; return menuSize; } + + /** + * Returns the action menu items (omitting separators) in this menu. + * + * @returns The MenuItem objects displayed in this menu. + */ + private getMenuItems(): MenuItem[] { + return this.menuItems.filter((item) => item instanceof MenuItem); + } } diff --git a/core/menu_separator.ts b/core/menu_separator.ts new file mode 100644 index 00000000000..6f7f468ad62 --- /dev/null +++ b/core/menu_separator.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as aria from './utils/aria.js'; + +/** + * Representation of a section separator in a menu. + */ +export class MenuSeparator { + /** + * DOM element representing this separator in a menu. + */ + private element: HTMLHRElement | null = null; + + /** + * Creates the DOM representation of this separator. + * + * @returns An
element. + */ + createDom(): HTMLHRElement { + this.element = document.createElement('hr'); + this.element.className = 'blocklyMenuSeparator'; + aria.setRole(this.element, aria.Role.SEPARATOR); + + return this.element; + } + + /** + * Disposes of this separator. + */ + dispose() { + this.element?.remove(); + this.element = null; + } +} diff --git a/core/menuitem.ts b/core/menuitem.ts index e9e7dc0dbca..b3ae33c5c12 100644 --- a/core/menuitem.ts +++ b/core/menuitem.ts @@ -41,7 +41,8 @@ export class MenuItem { private highlight = false; /** Bound function to call when this menu item is clicked. */ - private actionHandler: ((obj: this) => void) | null = null; + private actionHandler: ((obj: this, menuSelectEvent: Event) => void) | null = + null; /** * @param content Text caption to display as the content of the item, or a @@ -64,22 +65,19 @@ export class MenuItem { this.element = element; // Set class and style - // goog-menuitem* is deprecated, use blocklyMenuItem*. May 2020. element.className = - 'blocklyMenuItem goog-menuitem ' + - (this.enabled ? '' : 'blocklyMenuItemDisabled goog-menuitem-disabled ') + - (this.checked ? 'blocklyMenuItemSelected goog-option-selected ' : '') + - (this.highlight - ? 'blocklyMenuItemHighlight goog-menuitem-highlight ' - : '') + - (this.rightToLeft ? 'blocklyMenuItemRtl goog-menuitem-rtl ' : ''); + 'blocklyMenuItem ' + + (this.enabled ? '' : 'blocklyMenuItemDisabled ') + + (this.checked ? 'blocklyMenuItemSelected ' : '') + + (this.highlight ? 'blocklyMenuItemHighlight ' : '') + + (this.rightToLeft ? 'blocklyMenuItemRtl ' : ''); const content = document.createElement('div'); - content.className = 'blocklyMenuItemContent goog-menuitem-content'; + content.className = 'blocklyMenuItemContent'; // Add a checkbox for checkable menu items. if (this.checkable) { const checkbox = document.createElement('div'); - checkbox.className = 'blocklyMenuItemCheckbox goog-menuitem-checkbox'; + checkbox.className = 'blocklyMenuItemCheckbox '; content.appendChild(checkbox); } @@ -188,19 +186,13 @@ export class MenuItem { */ setHighlighted(highlight: boolean) { this.highlight = highlight; - const el = this.getElement(); if (el && this.isEnabled()) { - // goog-menuitem-highlight is deprecated, use blocklyMenuItemHighlight. - // May 2020. const name = 'blocklyMenuItemHighlight'; - const nameDep = 'goog-menuitem-highlight'; if (highlight) { dom.addClass(el, name); - dom.addClass(el, nameDep); } else { dom.removeClass(el, name); - dom.removeClass(el, nameDep); } } } @@ -229,11 +221,14 @@ export class MenuItem { * Performs the appropriate action when the menu item is activated * by the user. * + * @param menuSelectEvent the event that triggered the selection + * of the menu item. + * * @internal */ - performAction() { + performAction(menuSelectEvent: Event) { if (this.isEnabled() && this.actionHandler) { - this.actionHandler(this); + this.actionHandler(this, menuSelectEvent); } } @@ -245,7 +240,7 @@ export class MenuItem { * @param obj Used as the 'this' object in fn when called. * @internal */ - onAction(fn: (p1: MenuItem) => void, obj: object) { + onAction(fn: (p1: MenuItem, menuSelectEvent: Event) => void, obj: object) { this.actionHandler = fn.bind(obj); } } diff --git a/core/metrics_manager.ts b/core/metrics_manager.ts index 62a2614b617..a8470462ce3 100644 --- a/core/metrics_manager.ts +++ b/core/metrics_manager.ts @@ -76,7 +76,7 @@ export class MetricsManager implements IMetricsManager { * Gets the width, height and position of the toolbox on the workspace in * pixel coordinates. Returns 0 for the width and height if the workspace has * a simple toolbox instead of a category toolbox. To get the width and height - * of a simple toolbox, see {@link MetricsManager#getFlyoutMetrics}. + * of a simple toolbox, see {@link (MetricsManager:class).getFlyoutMetrics}. * * @returns The object with the width, height and position of the toolbox. */ diff --git a/core/names.ts b/core/names.ts index 4f4c72faac8..db7486f719e 100644 --- a/core/names.ts +++ b/core/names.ts @@ -11,9 +11,12 @@ */ // Former goog.module ID: Blockly.Names +import type {IVariableMap} from './interfaces/i_variable_map.js'; +import type { + IVariableModel, + IVariableState, +} from './interfaces/i_variable_model.js'; import {Msg} from './msg.js'; -// import * as Procedures from './procedures.js'; -import type {VariableMap} from './variable_map.js'; import * as Variables from './variables.js'; import type {Workspace} from './workspace.js'; @@ -39,7 +42,8 @@ export class Names { /** * The variable map from the workspace, containing Blockly variable models. */ - private variableMap: VariableMap | null = null; + private variableMap: IVariableMap> | null = + null; /** * @param reservedWordsList A comma-separated string of words that are illegal @@ -70,7 +74,7 @@ export class Names { * * @param map The map to track. */ - setVariableMap(map: VariableMap) { + setVariableMap(map: IVariableMap>) { this.variableMap = map; } @@ -95,7 +99,7 @@ export class Names { } const variable = this.variableMap.getVariableById(id); if (variable) { - return variable.name; + return variable.getName(); } return null; } diff --git a/core/navigator.ts b/core/navigator.ts new file mode 100644 index 00000000000..7a1c2d4ea10 --- /dev/null +++ b/core/navigator.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; +import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; +import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; +import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; +import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; + +type RuleList = INavigationPolicy[]; + +/** + * Class responsible for determining where focus should move in response to + * keyboard navigation commands. + */ +export class Navigator { + /** + * Map from classes to a corresponding ruleset to handle navigation from + * instances of that class. + */ + protected rules: RuleList = [ + new BlockNavigationPolicy(), + new FieldNavigationPolicy(), + new ConnectionNavigationPolicy(), + new WorkspaceNavigationPolicy(), + ]; + + /** + * Adds a navigation ruleset to this Navigator. + * + * @param policy A ruleset that determines where focus should move starting + * from an instance of its managed class. + */ + addNavigationPolicy(policy: INavigationPolicy) { + this.rules.push(policy); + } + + /** + * Returns the navigation ruleset associated with the given object instance's + * class. + * + * @param current An object to retrieve a navigation ruleset for. + * @returns The navigation ruleset of objects of the given object's class, or + * undefined if no ruleset has been registered for the object's class. + */ + private get( + current: IFocusableNode, + ): INavigationPolicy | undefined { + return this.rules.find((rule) => rule.isApplicable(current)); + } + + /** + * Returns the first child of the given object instance, if any. + * + * @param current The object to retrieve the first child of. + * @returns The first child node of the given object, if any. + */ + getFirstChild(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getFirstChild(current); + if (!result) return null; + // If the child isn't navigable, don't traverse into it; check its peers. + if (!this.get(result)?.isNavigable(result)) { + return this.getNextSibling(result); + } + return result; + } + + /** + * Returns the parent of the given object instance, if any. + * + * @param current The object to retrieve the parent of. + * @returns The parent node of the given object, if any. + */ + getParent(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getParent(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) return this.getParent(result); + return result; + } + + /** + * Returns the next sibling of the given object instance, if any. + * + * @param current The object to retrieve the next sibling node of. + * @returns The next sibling node of the given object, if any. + */ + getNextSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getNextSibling(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) { + return this.getNextSibling(result); + } + return result; + } + + /** + * Returns the previous sibling of the given object instance, if any. + * + * @param current The object to retrieve the previous sibling node of. + * @returns The previous sibling node of the given object, if any. + */ + getPreviousSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getPreviousSibling(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) { + return this.getPreviousSibling(result); + } + return result; + } +} diff --git a/core/procedures.ts b/core/procedures.ts index a16b0fce44a..73f06836cfe 100644 --- a/core/procedures.ts +++ b/core/procedures.ts @@ -42,6 +42,8 @@ import {IProcedureModel} from './interfaces/i_procedure_model.js'; import {Msg} from './msg.js'; import {Names} from './names.js'; import {ObservableProcedureMap} from './observable_procedure_map.js'; +import * as deprecation from './utils/deprecation.js'; +import type {FlyoutItemInfo} from './utils/toolbox.js'; import * as utilsXml from './utils/xml.js'; import * as Variables from './variables.js'; import type {Workspace} from './workspace.js'; @@ -238,7 +240,7 @@ export function rename(this: Field, name: string): string { * @param workspace The workspace containing procedures. * @returns Array of XML block elements. */ -export function flyoutCategory(workspace: WorkspaceSvg): Element[] { +function xmlFlyoutCategory(workspace: WorkspaceSvg): Element[] { const xmlList = []; if (Blocks['procedures_defnoreturn']) { // @@ -322,6 +324,109 @@ export function flyoutCategory(workspace: WorkspaceSvg): Element[] { return xmlList; } +/** + * Internal wrapper that returns the contents of the procedure category. + * + * @internal + * @param workspace The workspace to populate procedure blocks for. + */ +export function internalFlyoutCategory( + workspace: WorkspaceSvg, +): FlyoutItemInfo[] { + return flyoutCategory(workspace, false); +} + +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml: true, +): Element[]; +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml: false, +): FlyoutItemInfo[]; +/** + * Construct the blocks required by the flyout for the procedure category. + * + * @param workspace The workspace containing procedures. + * @param useXml True to return the contents as XML, false to use JSON. + * @returns List of flyout contents as either XML or JSON. + */ +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml = true, +): Element[] | FlyoutItemInfo[] { + if (useXml) { + deprecation.warn( + 'The XML return value of Blockly.Procedures.flyoutCategory()', + 'v12', + 'v13', + 'the same method, but handle a return type of FlyoutItemInfo[] (JSON) instead.', + ); + return xmlFlyoutCategory(workspace); + } + const blocks = []; + if (Blocks['procedures_defnoreturn']) { + blocks.push({ + 'kind': 'block', + 'type': 'procedures_defnoreturn', + 'gap': 16, + 'fields': { + 'NAME': Msg['PROCEDURES_DEFNORETURN_PROCEDURE'], + }, + }); + } + if (Blocks['procedures_defreturn']) { + blocks.push({ + 'kind': 'block', + 'type': 'procedures_defreturn', + 'gap': 16, + 'fields': { + 'NAME': Msg['PROCEDURES_DEFRETURN_PROCEDURE'], + }, + }); + } + if (Blocks['procedures_ifreturn']) { + blocks.push({ + 'kind': 'block', + 'type': 'procedures_ifreturn', + 'gap': 16, + }); + } + if (blocks.length) { + // Add slightly larger gap between system blocks and user calls. + blocks[blocks.length - 1]['gap'] = 24; + } + + /** + * Creates JSON block definitions for each of the given procedures. + * + * @param procedureList A list of procedures, each of which is defined by a + * three-element list of name, parameter list, and return value boolean. + * @param templateName The type of the block to generate. + */ + function populateProcedures( + procedureList: ProcedureTuple[], + templateName: string, + ) { + for (const [name, args] of procedureList) { + blocks.push({ + 'kind': 'block', + 'type': templateName, + 'gap': 16, + 'extraState': { + 'name': name, + 'params': args, + }, + }); + } + } + + const tuple = allProcedures(workspace); + populateProcedures(tuple[0], 'procedures_callnoreturn'); + populateProcedures(tuple[1], 'procedures_callreturn'); + return blocks; +} + /** * Updates the procedure mutator's flyout so that the arg block is not a * duplicate of another arg. diff --git a/core/registry.ts b/core/registry.ts index 60e8049797c..2b00b775dea 100644 --- a/core/registry.ts +++ b/core/registry.ts @@ -14,12 +14,19 @@ import type {IConnectionPreviewer} from './interfaces/i_connection_previewer.js' import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; import type {IDragger} from './interfaces/i_dragger.js'; import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; import type {IIcon} from './interfaces/i_icon.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IPaster} from './interfaces/i_paster.js'; import type {ISerializer} from './interfaces/i_serializer.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; -import type {Cursor} from './keyboard_nav/cursor.js'; +import type {IVariableMap} from './interfaces/i_variable_map.js'; +import type { + IVariableModel, + IVariableModelStatic, + IVariableState, +} from './interfaces/i_variable_model.js'; +import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Options} from './options.js'; import type {Renderer} from './renderers/common/renderer.js'; import type {Theme} from './theme.js'; @@ -71,7 +78,7 @@ export class Type<_T> { 'connectionPreviewer', ); - static CURSOR = new Type('cursor'); + static CURSOR = new Type('cursor'); static EVENT = new Type('event'); @@ -93,6 +100,8 @@ export class Type<_T> { 'flyoutsHorizontalToolbox', ); + static FLYOUT_INFLATER = new Type('flyoutInflater'); + static METRICS_MANAGER = new Type('metricsManager'); /** @@ -109,6 +118,14 @@ export class Type<_T> { /** @internal */ static PASTER = new Type>>('paster'); + + static VARIABLE_MODEL = new Type>( + 'variableModel', + ); + + static VARIABLE_MAP = new Type>>( + 'variableMap', + ); } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index c1d97dcddee..84905eeccc2 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -13,15 +13,21 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; -import * as common from './common.js'; import {config} from './config.js'; import {Connection} from './connection.js'; import type {ConnectionDB} from './connection_db.js'; import {ConnectionType} from './connection_type.js'; +import * as ContextMenu from './contextmenu.js'; +import {ContextMenuRegistry} from './contextmenu_registry.js'; import * as eventUtils from './events/utils.js'; +import {IContextMenu} from './interfaces/i_contextmenu.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import * as internalConstants from './internal_constants.js'; import {Coordinate} from './utils/coordinate.js'; +import * as svgMath from './utils/svg_math.js'; +import {WorkspaceSvg} from './workspace_svg.js'; /** Maximum randomness in workspace units for bumping a block. */ const BUMP_RANDOMNESS = 10; @@ -29,7 +35,10 @@ const BUMP_RANDOMNESS = 10; /** * Class for a connection between blocks that may be rendered on screen. */ -export class RenderedConnection extends Connection { +export class RenderedConnection + extends Connection + implements IContextMenu, IFocusableNode +{ // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. sourceBlock_!: BlockSvg; private readonly db: ConnectionDB; @@ -187,15 +196,12 @@ export class RenderedConnection extends Connection { ? inferiorRootBlock : superiorRootBlock; // Raise it to the top for extra visibility. - const selected = common.getSelected() === dynamicRootBlock; - if (!selected) dynamicRootBlock.addSelect(); if (dynamicRootBlock.RTL) { offsetX = -offsetX; } const dx = staticConnection.x + offsetX - dynamicConnection.x; const dy = staticConnection.y + offsetY - dynamicConnection.y; dynamicRootBlock.moveBy(dx, dy, ['bump']); - if (!selected) dynamicRootBlock.removeSelect(); } /** @@ -316,13 +322,28 @@ export class RenderedConnection extends Connection { /** Add highlighting around this connection. */ highlight() { this.highlighted = true; - this.getSourceBlock().queueRender(); + + // Note that this needs to be done synchronously (vs. queuing a render pass) + // since only a displayed element can be focused, and this focusable node is + // implemented to make itself visible immediately prior to receiving DOM + // focus. It's expected that the connection's position should already be + // correct by this point (otherwise it will be corrected in a subsequent + // draw pass). + const highlightSvg = this.findHighlightSvg(); + if (highlightSvg) { + highlightSvg.style.display = ''; + } } /** Remove the highlighting around this connection. */ unhighlight() { this.highlighted = false; - this.getSourceBlock().queueRender(); + + // Note that this is done synchronously for parity with highlight(). + const highlightSvg = this.findHighlightSvg(); + if (highlightSvg) { + highlightSvg.style.display = 'none'; + } } /** Returns true if this connection is highlighted, false otherwise. */ @@ -533,21 +554,6 @@ export class RenderedConnection extends Connection { childBlock.updateDisabled(); childBlock.queueRender(); - // If either block being connected was selected, visually un- and reselect - // it. This has the effect of moving the selection path to the end of the - // list of child nodes in the DOM. Since SVG z-order is determined by node - // order in the DOM, this works around an issue where the selection outline - // path could be partially obscured by a new block inserted after it in the - // DOM. - const selection = common.getSelected(); - const selectedBlock = - (selection === parentBlock && parentBlock) || - (selection === childBlock && childBlock); - if (selectedBlock) { - selectedBlock.removeSelect(); - selectedBlock.addSelect(); - } - // The input the child block is connected to (if any). const parentInput = parentBlock.getInputWithBlock(childBlock); if (parentInput) { @@ -588,6 +594,75 @@ export class RenderedConnection extends Connection { this.sourceBlock_.queueRender(); return this; } + + /** + * Handles showing the context menu when it is opened on a connection. + * Note that typically the context menu can't be opened with the mouse + * on a connection, because you can't select a connection. But keyboard + * users may open the context menu with a keyboard shortcut. + * + * @param e Event that triggered the opening of the context menu. + */ + showContextMenu(e: Event): void { + const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( + {focusedNode: this}, + e, + ); + + if (!menuOptions.length) return; + + const block = this.getSourceBlock(); + const workspace = block.workspace; + + let location; + if (e instanceof PointerEvent) { + location = new Coordinate(e.clientX, e.clientY); + } else { + const connectionWSCoords = new Coordinate(this.x, this.y); + const connectionScreenCoords = svgMath.wsToScreenCoordinates( + workspace, + connectionWSCoords, + ); + location = connectionScreenCoords.translate(block.RTL ? -5 : 5, 5); + } + + ContextMenu.show(e, menuOptions, block.RTL, workspace, location); + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + const highlightSvg = this.findHighlightSvg(); + if (highlightSvg) return highlightSvg; + throw new Error('No highlight SVG found corresponding to this connection.'); + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.getSourceBlock().workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.highlight(); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unhighlight(); + } + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + private findHighlightSvg(): SVGElement | null { + // This cast is valid as TypeScript's definition is wrong. See: + // https://github.com/microsoft/TypeScript/issues/60996. + return document.getElementById(this.id) as + | unknown + | null as SVGElement | null; + } } export namespace RenderedConnection { diff --git a/core/renderers/common/block_rendering.ts b/core/renderers/common/block_rendering.ts index 27fbbd538c7..7ac27ae8b73 100644 --- a/core/renderers/common/block_rendering.ts +++ b/core/renderers/common/block_rendering.ts @@ -33,7 +33,6 @@ import {Types} from '../measurables/types.js'; import {Drawer} from './drawer.js'; import type {IPathObject} from './i_path_object.js'; import {RenderInfo} from './info.js'; -import {MarkerSvg} from './marker_svg.js'; import {PathObject} from './path_object.js'; import {Renderer} from './renderer.js'; @@ -94,7 +93,6 @@ export { InRowSpacer, IPathObject, JaggedEdge, - MarkerSvg, Measurable, NextConnection, OutputConnection, diff --git a/core/renderers/common/constants.ts b/core/renderers/common/constants.ts index 078fc01d648..c5a7a759c5c 100644 --- a/core/renderers/common/constants.ts +++ b/core/renderers/common/constants.ts @@ -727,7 +727,10 @@ export class ConstantProvider { svgPaths.point(70, -height), svgPaths.point(width, 0), ]); - return {height, width, path: mainPath}; + // Height is actually the Y position of the control points defining the + // curve of the hat; the hat's actual rendered height is 3/4 of the control + // points' Y position, per https://stackoverflow.com/a/5327329 + return {height: height * 0.75, width, path: mainPath}; } /** @@ -923,8 +926,18 @@ export class ConstantProvider { * @param svg The root of the workspace's SVG. * @param tagName The name to use for the CSS style tag. * @param selector The CSS selector to use. + * @param injectionDivIfIsParent The div containing the parent workspace and + * all related workspaces and block containers, if this renderer is for the + * parent workspace. CSS variables representing SVG patterns will be scoped + * to this container. Child workspaces should not override the CSS variables + * created by the parent and thus do not need access to the injection div. */ - createDom(svg: SVGElement, tagName: string, selector: string) { + createDom( + svg: SVGElement, + tagName: string, + selector: string, + injectionDivIfIsParent?: HTMLElement, + ) { this.injectCSS_(tagName, selector); /* @@ -1031,6 +1044,24 @@ export class ConstantProvider { this.disabledPattern = disabledPattern; this.createDebugFilter(); + + if (injectionDivIfIsParent) { + // If this renderer is for the parent workspace, add CSS variables scoped + // to the injection div referencing the created patterns so that CSS can + // apply the patterns to any element in the injection div. + injectionDivIfIsParent.style.setProperty( + '--blocklyEmbossFilter', + `url(#${this.embossFilterId})`, + ); + injectionDivIfIsParent.style.setProperty( + '--blocklyDisabledPattern', + `url(#${this.disabledPatternId})`, + ); + injectionDivIfIsParent.style.setProperty( + '--blocklyDebugFilter', + `url(#${this.debugFilterId})`, + ); + } } /** @@ -1132,14 +1163,14 @@ export class ConstantProvider { `${selector} .blocklyText {`, `fill: #fff;`, `}`, - `${selector} .blocklyNonEditableText>rect,`, - `${selector} .blocklyEditableText>rect {`, + `${selector} .blocklyNonEditableField>rect,`, + `${selector} .blocklyEditableField>rect {`, `fill: ${this.FIELD_BORDER_RECT_COLOUR};`, `fill-opacity: .6;`, `stroke: none;`, `}`, - `${selector} .blocklyNonEditableText>text,`, - `${selector} .blocklyEditableText>text {`, + `${selector} .blocklyNonEditableField>text,`, + `${selector} .blocklyEditableField>text {`, `fill: #000;`, `}`, @@ -1154,7 +1185,7 @@ export class ConstantProvider { `}`, // Editable field hover. - `${selector} .blocklyEditableText:not(.editing):hover>rect {`, + `${selector} .blocklyEditableField:not(.blocklyEditing):hover>rect {`, `stroke: #fff;`, `stroke-width: 2;`, `}`, diff --git a/core/renderers/common/drawer.ts b/core/renderers/common/drawer.ts index 59a856011f2..7046406adc7 100644 --- a/core/renderers/common/drawer.ts +++ b/core/renderers/common/drawer.ts @@ -15,7 +15,6 @@ import type {ExternalValueInput} from '../measurables/external_value_input.js'; import type {Field} from '../measurables/field.js'; import type {Icon} from '../measurables/icon.js'; import type {InlineInput} from '../measurables/inline_input.js'; -import type {PreviousConnection} from '../measurables/previous_connection.js'; import type {Row} from '../measurables/row.js'; import {Types} from '../measurables/types.js'; import type {ConstantProvider, Notch, PuzzleTab} from './constants.js'; @@ -116,13 +115,8 @@ export class Drawer { this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topLeft; } else if (Types.isRightRoundedCorner(elem)) { this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topRight; - } else if ( - Types.isPreviousConnection(elem) && - elem instanceof Connection - ) { - this.outlinePath_ += ( - (elem as PreviousConnection).shape as Notch - ).pathLeft; + } else if (Types.isPreviousConnection(elem)) { + this.outlinePath_ += (elem.shape as Notch).pathLeft; } else if (Types.isHat(elem)) { this.outlinePath_ += this.constants_.START_HAT.path; } else if (Types.isSpacer(elem)) { @@ -217,7 +211,7 @@ export class Drawer { let rightCornerYOffset = 0; let outlinePath = ''; for (let i = elems.length - 1, elem; (elem = elems[i]); i--) { - if (Types.isNextConnection(elem) && elem instanceof Connection) { + if (Types.isNextConnection(elem)) { outlinePath += (elem.shape as Notch).pathRight; } else if (Types.isLeftSquareCorner(elem)) { outlinePath += svgPaths.lineOnAxis('H', bottomRow.xPos); @@ -269,9 +263,9 @@ export class Drawer { for (let i = 0, row; (row = this.info_.rows[i]); i++) { for (let j = 0, elem; (elem = row.elements[j]); j++) { if (Types.isInlineInput(elem)) { - this.drawInlineInput_(elem as InlineInput); + this.drawInlineInput_(elem); } else if (Types.isIcon(elem) || Types.isField(elem)) { - this.layoutField_(elem as Field | Icon); + this.layoutField_(elem); } } } @@ -295,13 +289,13 @@ export class Drawer { } if (Types.isIcon(fieldInfo)) { - const icon = (fieldInfo as Icon).icon; + const icon = fieldInfo.icon; icon.setOffsetInBlock(new Coordinate(xPos, yPos)); if (this.info_.isInsertionMarker) { icon.hideForInsertionMarker(); } } else { - const svgGroup = (fieldInfo as Field).field.getSvgRoot()!; + const svgGroup = fieldInfo.field.getSvgRoot()!; svgGroup.setAttribute( 'transform', 'translate(' + xPos + ',' + yPos + ')' + scale, @@ -441,19 +435,16 @@ export class Drawer { for (const elem of row.elements) { if (!(elem instanceof Connection)) continue; - if (elem.highlighted) { - this.drawConnectionHighlightPath(elem); - } else { - this.block_.pathObject.removeConnectionHighlight?.( - elem.connectionModel, - ); + const highlightSvg = this.drawConnectionHighlightPath(elem); + if (highlightSvg) { + highlightSvg.style.display = elem.highlighted ? '' : 'none'; } } } } /** Returns a path to highlight the given connection. */ - drawConnectionHighlightPath(measurable: Connection) { + drawConnectionHighlightPath(measurable: Connection): SVGElement | undefined { const conn = measurable.connectionModel; let path = ''; if ( @@ -465,7 +456,7 @@ export class Drawer { path = this.getStatementConnectionHighlightPath(measurable); } const block = conn.getSourceBlock(); - block.pathObject.addConnectionHighlight?.( + return block.pathObject.addConnectionHighlight?.( conn, path, conn.getOffsetInBlock(), diff --git a/core/renderers/common/i_path_object.ts b/core/renderers/common/i_path_object.ts index 3a78035e156..a68c3a41148 100644 --- a/core/renderers/common/i_path_object.ts +++ b/core/renderers/common/i_path_object.ts @@ -30,18 +30,6 @@ export interface IPathObject { /** The primary path of the block. */ style: BlockStyle; - /** - * Holds the cursors SVG element when the cursor is attached to the block. - * This is null if there is no cursor on the block. - */ - cursorSvg: SVGElement | null; - - /** - * Holds the markers SVG element when the marker is attached to the block. - * This is null if there is no marker on the block. - */ - markerSvg: SVGElement | null; - /** * Set the path generated by the renderer onto the respective SVG element. * @@ -49,42 +37,11 @@ export interface IPathObject { */ setPath(pathString: string): void; - /** - * Apply the stored colours to the block's path, taking into account whether - * the paths belong to a shadow block. - * - * @param block The source block. - */ - applyColour(block: BlockSvg): void; - - /** - * Update the style. - * - * @param blockStyle The block style to use. - */ - setStyle(blockStyle: BlockStyle): void; - /** * Flip the SVG paths in RTL. */ flipRTL(): void; - /** - * Add the cursor SVG to this block's SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the block SVG - * group. - */ - setCursorSvg(cursorSvg: SVGElement): void; - - /** - * Add the marker SVG to this block's SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the block SVG - * group. - */ - setMarkerSvg(markerSvg: SVGElement): void; - /** * Set whether the block shows a highlight or not. Block highlighting is * often used to visually mark blocks currently being executed. @@ -128,10 +85,25 @@ export interface IPathObject { connectionPath: string, offset: Coordinate, rtl: boolean, - ): void; + ): SVGElement; + + /** + * Apply the stored colours to the block's path, taking into account whether + * the paths belong to a shadow block. + * + * @param block The source block. + */ + applyColour?(block: BlockSvg): void; /** * Removes any highlight associated with the given connection, if it exists. */ removeConnectionHighlight?(connection: RenderedConnection): void; + + /** + * Update the style. + * + * @param blockStyle The block style to use. + */ + setStyle?(blockStyle: BlockStyle): void; } diff --git a/core/renderers/common/info.ts b/core/renderers/common/info.ts index ff073ace48b..0e4d3e9460c 100644 --- a/core/renderers/common/info.ts +++ b/core/renderers/common/info.ts @@ -231,7 +231,6 @@ export class RenderInfo { if (hasHat) { const hat = new Hat(this.constants_); this.topRow.elements.push(hat); - this.topRow.capline = hat.ascenderHeight; } else if (hasPrevious) { this.topRow.hasPreviousConnection = true; this.topRow.connection = new PreviousConnection( @@ -458,6 +457,11 @@ export class RenderInfo { } } + // Don't add padding after zero-width fields. + if (prev && Types.isField(prev) && prev.width === 0) { + return this.constants_.NO_PADDING; + } + return this.constants_.MEDIUM_PADDING; } @@ -672,20 +676,17 @@ export class RenderInfo { return row.yPos + elem.height / 2; } if (Types.isBottomRow(row)) { - const bottomRow = row as BottomRow; - const baseline = - bottomRow.yPos + bottomRow.height - bottomRow.descenderHeight; + const baseline = row.yPos + row.height - row.descenderHeight; if (Types.isNextConnection(elem)) { return baseline + elem.height / 2; } return baseline - elem.height / 2; } if (Types.isTopRow(row)) { - const topRow = row as TopRow; if (Types.isHat(elem)) { - return topRow.capline - elem.height / 2; + return row.capline - elem.height / 2; } - return topRow.capline + elem.height / 2; + return row.capline + elem.height / 2; } return row.yPos + row.height / 2; } diff --git a/core/renderers/common/marker_svg.ts b/core/renderers/common/marker_svg.ts deleted file mode 100644 index 057324f0346..00000000000 --- a/core/renderers/common/marker_svg.ts +++ /dev/null @@ -1,767 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Former goog.module ID: Blockly.blockRendering.MarkerSvg - -// Unused import preserved for side-effects. Remove if unneeded. -import '../../events/events_marker_move.js'; - -import type {BlockSvg} from '../../block_svg.js'; -import type {Connection} from '../../connection.js'; -import {ConnectionType} from '../../connection_type.js'; -import {EventType} from '../../events/type.js'; -import * as eventUtils from '../../events/utils.js'; -import type {Field} from '../../field.js'; -import {FlyoutButton} from '../../flyout_button.js'; -import type {IASTNodeLocationSvg} from '../../interfaces/i_ast_node_location_svg.js'; -import {ASTNode} from '../../keyboard_nav/ast_node.js'; -import type {Marker} from '../../keyboard_nav/marker.js'; -import type {RenderedConnection} from '../../rendered_connection.js'; -import * as dom from '../../utils/dom.js'; -import {Svg} from '../../utils/svg.js'; -import * as svgPaths from '../../utils/svg_paths.js'; -import type {WorkspaceSvg} from '../../workspace_svg.js'; -import type {ConstantProvider, Notch, PuzzleTab} from './constants.js'; - -/** The name of the CSS class for a cursor. */ -const CURSOR_CLASS = 'blocklyCursor'; - -/** The name of the CSS class for a marker. */ -const MARKER_CLASS = 'blocklyMarker'; - -/** - * What we multiply the height by to get the height of the marker. - * Only used for the block and block connections. - */ -const HEIGHT_MULTIPLIER = 3 / 4; - -/** - * Class for a marker, containing methods for graphically rendering a marker as - * SVG. - */ -export class MarkerSvg { - /** - * The workspace, field, or block that the marker SVG element should be - * attached to. - */ - protected parent: IASTNodeLocationSvg | null = null; - - /** The current SVG element for the marker. */ - currentMarkerSvg: SVGElement | null = null; - colour_: string; - - /** The root SVG group containing the marker. */ - protected markerSvg_: SVGGElement | null = null; - protected svgGroup_: SVGGElement | null = null; - - protected markerBlock_: SVGPathElement | null = null; - - protected markerInput_: SVGPathElement | null = null; - protected markerSvgLine_: SVGRectElement | null = null; - - protected markerSvgRect_: SVGRectElement | null = null; - - /** The constants necessary to draw the marker. */ - protected constants_: ConstantProvider; - - /** - * @param workspace The workspace the marker belongs to. - * @param constants The constants for the renderer. - * @param marker The marker to draw. - */ - constructor( - protected readonly workspace: WorkspaceSvg, - constants: ConstantProvider, - protected readonly marker: Marker, - ) { - this.constants_ = constants; - - const defaultColour = this.isCursor() - ? this.constants_.CURSOR_COLOUR - : this.constants_.MARKER_COLOUR; - - /** The colour of the marker. */ - this.colour_ = marker.colour || defaultColour; - } - - /** - * Return the root node of the SVG or null if none exists. - * - * @returns The root SVG node. - */ - getSvgRoot(): SVGElement | null { - return this.svgGroup_; - } - - /** - * Get the marker. - * - * @returns The marker to draw for. - */ - getMarker(): Marker { - return this.marker; - } - - /** - * True if the marker should be drawn as a cursor, false otherwise. - * A cursor is drawn as a flashing line. A marker is drawn as a solid line. - * - * @returns True if the marker is a cursor, false otherwise. - */ - isCursor(): boolean { - return this.marker.type === 'cursor'; - } - - /** - * Create the DOM element for the marker. - * - * @returns The marker controls SVG group. - */ - createDom(): SVGElement { - const className = this.isCursor() ? CURSOR_CLASS : MARKER_CLASS; - - this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': className}); - - this.createDomInternal_(); - return this.svgGroup_; - } - - /** - * Attaches the SVG root of the marker to the SVG group of the parent. - * - * @param newParent The workspace, field, or block that the marker SVG element - * should be attached to. - */ - protected setParent_(newParent: IASTNodeLocationSvg) { - if (!this.isCursor()) { - if (this.parent) { - this.parent.setMarkerSvg(null); - } - newParent.setMarkerSvg(this.getSvgRoot()); - } else { - if (this.parent) { - this.parent.setCursorSvg(null); - } - newParent.setCursorSvg(this.getSvgRoot()); - } - this.parent = newParent; - } - - /** - * Update the marker. - * - * @param oldNode The previous node the marker was on or null. - * @param curNode The node that we want to draw the marker for. - */ - draw(oldNode: ASTNode, curNode: ASTNode) { - if (!curNode) { - this.hide(); - return; - } - - this.constants_ = this.workspace.getRenderer().getConstants(); - - const defaultColour = this.isCursor() - ? this.constants_.CURSOR_COLOUR - : this.constants_.MARKER_COLOUR; - this.colour_ = this.marker.colour || defaultColour; - this.applyColour_(curNode); - - this.showAtLocation_(curNode); - - this.fireMarkerEvent(oldNode, curNode); - - // Ensures the marker will be visible immediately after the move. - const animate = this.currentMarkerSvg!.childNodes[0]; - if ( - animate !== undefined && - (animate as SVGAnimationElement).beginElement - ) { - (animate as SVGAnimationElement).beginElement(); - } - } - - /** - * Update the marker's visible state based on the type of curNode.. - * - * @param curNode The node that we want to draw the marker for. - */ - protected showAtLocation_(curNode: ASTNode) { - const curNodeAsConnection = curNode.getLocation() as Connection; - const connectionType = curNodeAsConnection.type; - if (curNode.getType() === ASTNode.types.BLOCK) { - this.showWithBlock_(curNode); - } else if (curNode.getType() === ASTNode.types.OUTPUT) { - this.showWithOutput_(curNode); - } else if (connectionType === ConnectionType.INPUT_VALUE) { - this.showWithInput_(curNode); - } else if (connectionType === ConnectionType.NEXT_STATEMENT) { - this.showWithNext_(curNode); - } else if (curNode.getType() === ASTNode.types.PREVIOUS) { - this.showWithPrevious_(curNode); - } else if (curNode.getType() === ASTNode.types.FIELD) { - this.showWithField_(curNode); - } else if (curNode.getType() === ASTNode.types.WORKSPACE) { - this.showWithCoordinates_(curNode); - } else if (curNode.getType() === ASTNode.types.STACK) { - this.showWithStack_(curNode); - } else if (curNode.getType() === ASTNode.types.BUTTON) { - this.showWithButton_(curNode); - } - } - - /************************** - * Display - **************************/ - - /** - * Show the marker as a combination of the previous connection and block, - * the output connection and block, or just the block. - * - * @param curNode The node to draw the marker for. - */ - protected showWithBlockPrevOutput(curNode: ASTNode) { - const block = curNode.getSourceBlock() as BlockSvg; - const width = block.width; - const height = block.height; - const markerHeight = height * HEIGHT_MULTIPLIER; - const markerOffset = this.constants_.CURSOR_BLOCK_PADDING; - - if (block.previousConnection) { - const connectionShape = this.constants_.shapeFor( - block.previousConnection, - ) as Notch; - this.positionPrevious_( - width, - markerOffset, - markerHeight, - connectionShape, - ); - } else if (block.outputConnection) { - const connectionShape = this.constants_.shapeFor( - block.outputConnection, - ) as PuzzleTab; - this.positionOutput_(width, height, connectionShape); - } else { - this.positionBlock_(width, markerOffset, markerHeight); - } - this.setParent_(block); - this.showCurrent_(); - } - - /** - * Position and display the marker for a block. - * - * @param curNode The node to draw the marker for. - */ - protected showWithBlock_(curNode: ASTNode) { - this.showWithBlockPrevOutput(curNode); - } - - /** - * Position and display the marker for a previous connection. - * - * @param curNode The node to draw the marker for. - */ - protected showWithPrevious_(curNode: ASTNode) { - this.showWithBlockPrevOutput(curNode); - } - - /** - * Position and display the marker for an output connection. - * - * @param curNode The node to draw the marker for. - */ - protected showWithOutput_(curNode: ASTNode) { - this.showWithBlockPrevOutput(curNode); - } - - /** - * Position and display the marker for a workspace coordinate. - * This is a horizontal line. - * - * @param curNode The node to draw the marker for. - */ - protected showWithCoordinates_(curNode: ASTNode) { - const wsCoordinate = curNode.getWsCoordinate(); - let x = wsCoordinate.x; - const y = wsCoordinate.y; - - if (this.workspace.RTL) { - x -= this.constants_.CURSOR_WS_WIDTH; - } - - this.positionLine_(x, y, this.constants_.CURSOR_WS_WIDTH); - this.setParent_(this.workspace); - this.showCurrent_(); - } - - /** - * Position and display the marker for a field. - * This is a box around the field. - * - * @param curNode The node to draw the marker for. - */ - protected showWithField_(curNode: ASTNode) { - const field = curNode.getLocation() as Field; - const width = field.getSize().width; - const height = field.getSize().height; - - this.positionRect_(0, 0, width, height); - this.setParent_(field); - this.showCurrent_(); - } - - /** - * Position and display the marker for an input. - * This is a puzzle piece. - * - * @param curNode The node to draw the marker for. - */ - protected showWithInput_(curNode: ASTNode) { - const connection = curNode.getLocation() as RenderedConnection; - const sourceBlock = connection.getSourceBlock(); - - this.positionInput_(connection); - this.setParent_(sourceBlock); - this.showCurrent_(); - } - - /** - * Position and display the marker for a next connection. - * This is a horizontal line. - * - * @param curNode The node to draw the marker for. - */ - protected showWithNext_(curNode: ASTNode) { - const connection = curNode.getLocation() as RenderedConnection; - const targetBlock = connection.getSourceBlock(); - let x = 0; - const y = connection.getOffsetInBlock().y; - const width = targetBlock.getHeightWidth().width; - if (this.workspace.RTL) { - x = -width; - } - this.positionLine_(x, y, width); - this.setParent_(targetBlock); - this.showCurrent_(); - } - - /** - * Position and display the marker for a stack. - * This is a box with extra padding around the entire stack of blocks. - * - * @param curNode The node to draw the marker for. - */ - protected showWithStack_(curNode: ASTNode) { - const block = curNode.getLocation() as BlockSvg; - - // Gets the height and width of entire stack. - const heightWidth = block.getHeightWidth(); - - // Add padding so that being on a stack looks different than being on a - // block. - const width = heightWidth.width + this.constants_.CURSOR_STACK_PADDING; - const height = heightWidth.height + this.constants_.CURSOR_STACK_PADDING; - - // Shift the rectangle slightly to upper left so padding is equal on all - // sides. - const xPadding = -this.constants_.CURSOR_STACK_PADDING / 2; - const yPadding = -this.constants_.CURSOR_STACK_PADDING / 2; - - let x = xPadding; - const y = yPadding; - - if (this.workspace.RTL) { - x = -(width + xPadding); - } - this.positionRect_(x, y, width, height); - this.setParent_(block); - this.showCurrent_(); - } - - /** - * Position and display the marker for a flyout button. - * This is a box with extra padding around the button. - * - * @param curNode The node to draw the marker for. - */ - protected showWithButton_(curNode: ASTNode) { - const button = curNode.getLocation() as FlyoutButton; - - // Gets the height and width of entire stack. - const heightWidth = {height: button.height, width: button.width}; - - // Add padding so that being on a button looks similar to being on a stack. - const width = heightWidth.width + this.constants_.CURSOR_STACK_PADDING; - const height = heightWidth.height + this.constants_.CURSOR_STACK_PADDING; - - // Shift the rectangle slightly to upper left so padding is equal on all - // sides. - const xPadding = -this.constants_.CURSOR_STACK_PADDING / 2; - const yPadding = -this.constants_.CURSOR_STACK_PADDING / 2; - - let x = xPadding; - const y = yPadding; - - if (this.workspace.RTL) { - x = -(width + xPadding); - } - this.positionRect_(x, y, width, height); - this.setParent_(button); - this.showCurrent_(); - } - - /** Show the current marker. */ - protected showCurrent_() { - this.hide(); - if (this.currentMarkerSvg) { - this.currentMarkerSvg.style.display = ''; - } - } - - /************************** - * Position - **************************/ - - /** - * Position the marker for a block. - * Displays an outline of the top half of a rectangle around a block. - * - * @param width The width of the block. - * @param markerOffset The extra padding for around the block. - * @param markerHeight The height of the marker. - */ - protected positionBlock_( - width: number, - markerOffset: number, - markerHeight: number, - ) { - const markerPath = - svgPaths.moveBy(-markerOffset, markerHeight) + - svgPaths.lineOnAxis('V', -markerOffset) + - svgPaths.lineOnAxis('H', width + markerOffset * 2) + - svgPaths.lineOnAxis('V', markerHeight); - if (!this.markerBlock_) { - throw new Error( - 'createDom should be called before positioning the marker', - ); - } - this.markerBlock_.setAttribute('d', markerPath); - if (this.workspace.RTL) { - this.flipRtl(this.markerBlock_); - } - this.currentMarkerSvg = this.markerBlock_; - } - - /** - * Position the marker for an input connection. - * Displays a filled in puzzle piece. - * - * @param connection The connection to position marker around. - */ - protected positionInput_(connection: RenderedConnection) { - const x = connection.getOffsetInBlock().x; - const y = connection.getOffsetInBlock().y; - - const path = - svgPaths.moveTo(0, 0) + - (this.constants_.shapeFor(connection) as PuzzleTab).pathDown; - - this.markerInput_!.setAttribute('d', path); - this.markerInput_!.setAttribute( - 'transform', - 'translate(' + - x + - ',' + - y + - ')' + - (this.workspace.RTL ? ' scale(-1 1)' : ''), - ); - this.currentMarkerSvg = this.markerInput_; - } - - /** - * Move and show the marker at the specified coordinate in workspace units. - * Displays a horizontal line. - * - * @param x The new x, in workspace units. - * @param y The new y, in workspace units. - * @param width The new width, in workspace units. - */ - protected positionLine_(x: number, y: number, width: number) { - if (!this.markerSvgLine_) { - throw new Error('createDom should be called before positioning the line'); - } - this.markerSvgLine_.setAttribute('x', `${x}`); - this.markerSvgLine_.setAttribute('y', `${y}`); - this.markerSvgLine_.setAttribute('width', `${width}`); - this.currentMarkerSvg = this.markerSvgLine_; - } - - /** - * Position the marker for an output connection. - * Displays a puzzle outline and the top and bottom path. - * - * @param width The width of the block. - * @param height The height of the block. - * @param connectionShape The shape object for the connection. - */ - protected positionOutput_( - width: number, - height: number, - connectionShape: PuzzleTab, - ) { - if (!this.markerBlock_) { - throw new Error( - 'createDom should be called before positioning the output', - ); - } - const markerPath = - svgPaths.moveBy(width, 0) + - svgPaths.lineOnAxis('h', -(width - connectionShape.width)) + - svgPaths.lineOnAxis('v', this.constants_.TAB_OFFSET_FROM_TOP) + - connectionShape.pathDown + - svgPaths.lineOnAxis('V', height) + - svgPaths.lineOnAxis('H', width); - this.markerBlock_.setAttribute('d', markerPath); - if (this.workspace.RTL) { - this.flipRtl(this.markerBlock_); - } - this.currentMarkerSvg = this.markerBlock_; - } - - /** - * Position the marker for a previous connection. - * Displays a half rectangle with a notch in the top to represent the previous - * connection. - * - * @param width The width of the block. - * @param markerOffset The offset of the marker from around the block. - * @param markerHeight The height of the marker. - * @param connectionShape The shape object for the connection. - */ - protected positionPrevious_( - width: number, - markerOffset: number, - markerHeight: number, - connectionShape: Notch, - ) { - if (!this.markerBlock_) { - throw new Error( - 'createDom should be called before positioning the previous connection marker', - ); - } - const markerPath = - svgPaths.moveBy(-markerOffset, markerHeight) + - svgPaths.lineOnAxis('V', -markerOffset) + - svgPaths.lineOnAxis('H', this.constants_.NOTCH_OFFSET_LEFT) + - connectionShape.pathLeft + - svgPaths.lineOnAxis('H', width + markerOffset * 2) + - svgPaths.lineOnAxis('V', markerHeight); - this.markerBlock_.setAttribute('d', markerPath); - if (this.workspace.RTL) { - this.flipRtl(this.markerBlock_); - } - this.currentMarkerSvg = this.markerBlock_; - } - - /** - * Move and show the marker at the specified coordinate in workspace units. - * Displays a filled in rectangle. - * - * @param x The new x, in workspace units. - * @param y The new y, in workspace units. - * @param width The new width, in workspace units. - * @param height The new height, in workspace units. - */ - protected positionRect_(x: number, y: number, width: number, height: number) { - if (!this.markerSvgRect_) { - throw new Error('createDom should be called before positioning the rect'); - } - this.markerSvgRect_.setAttribute('x', `${x}`); - this.markerSvgRect_.setAttribute('y', `${y}`); - this.markerSvgRect_.setAttribute('width', `${width}`); - this.markerSvgRect_.setAttribute('height', `${height}`); - this.currentMarkerSvg = this.markerSvgRect_; - } - - /** - * Flip the SVG paths in RTL. - * - * @param markerSvg The marker that we want to flip. - */ - private flipRtl(markerSvg: SVGElement) { - markerSvg.setAttribute('transform', 'scale(-1 1)'); - } - - /** Hide the marker. */ - hide() { - if ( - !this.markerSvgLine_ || - !this.markerSvgRect_ || - !this.markerInput_ || - !this.markerBlock_ - ) { - throw new Error('createDom should be called before hiding the marker'); - } - this.markerSvgLine_.style.display = 'none'; - this.markerSvgRect_.style.display = 'none'; - this.markerInput_.style.display = 'none'; - this.markerBlock_.style.display = 'none'; - } - - /** - * Fire event for the marker or marker. - * - * @param oldNode The old node the marker used to be on. - * @param curNode The new node the marker is currently on. - */ - protected fireMarkerEvent(oldNode: ASTNode, curNode: ASTNode) { - const curBlock = curNode.getSourceBlock(); - const event = new (eventUtils.get(EventType.MARKER_MOVE))( - curBlock, - this.isCursor(), - oldNode, - curNode, - ); - eventUtils.fire(event); - } - - /** - * Get the properties to make a marker blink. - * - * @returns The object holding attributes to make the marker blink. - */ - protected getBlinkProperties_(): {[key: string]: string} { - return { - 'attributeType': 'XML', - 'attributeName': 'fill', - 'dur': '1s', - 'values': this.colour_ + ';transparent;transparent;', - 'repeatCount': 'indefinite', - }; - } - - /** - * Create the marker SVG. - * - * @returns The SVG node created. - */ - protected createDomInternal_(): Element { - /* This markup will be generated and added to the .svgGroup_: - - - - - - */ - - this.markerSvg_ = dom.createSvgElement( - Svg.G, - { - 'width': this.constants_.CURSOR_WS_WIDTH, - 'height': this.constants_.WS_CURSOR_HEIGHT, - }, - this.svgGroup_, - ); - - // A horizontal line used to represent a workspace coordinate or next - // connection. - this.markerSvgLine_ = dom.createSvgElement( - Svg.RECT, - { - 'width': this.constants_.CURSOR_WS_WIDTH, - 'height': this.constants_.WS_CURSOR_HEIGHT, - }, - this.markerSvg_, - ); - - // A filled in rectangle used to represent a stack. - this.markerSvgRect_ = dom.createSvgElement( - Svg.RECT, - { - 'class': 'blocklyVerticalMarker', - 'rx': 10, - 'ry': 10, - }, - this.markerSvg_, - ); - - // A filled in puzzle piece used to represent an input value. - this.markerInput_ = dom.createSvgElement( - Svg.PATH, - {'transform': ''}, - this.markerSvg_, - ); - - // A path used to represent a previous connection and a block, an output - // connection and a block, or a block. - this.markerBlock_ = dom.createSvgElement( - Svg.PATH, - { - 'transform': '', - 'fill': 'none', - 'stroke-width': this.constants_.CURSOR_STROKE_WIDTH, - }, - this.markerSvg_, - ); - - this.hide(); - - // Markers and stack markers don't blink. - if (this.isCursor()) { - const blinkProperties = this.getBlinkProperties_(); - dom.createSvgElement(Svg.ANIMATE, blinkProperties, this.markerSvgLine_); - dom.createSvgElement(Svg.ANIMATE, blinkProperties, this.markerInput_); - dom.createSvgElement( - Svg.ANIMATE, - {...blinkProperties, attributeName: 'stroke'}, - this.markerBlock_, - ); - } - - return this.markerSvg_; - } - - /** - * Apply the marker's colour. - * - * @param _curNode The node that we want to draw the marker for. - */ - protected applyColour_(_curNode: ASTNode) { - if ( - !this.markerSvgLine_ || - !this.markerSvgRect_ || - !this.markerInput_ || - !this.markerBlock_ - ) { - throw new Error( - 'createDom should be called before applying color to the markerj', - ); - } - this.markerSvgLine_.setAttribute('fill', this.colour_); - this.markerSvgRect_.setAttribute('stroke', this.colour_); - this.markerInput_.setAttribute('fill', this.colour_); - this.markerBlock_.setAttribute('stroke', this.colour_); - - if (this.isCursor()) { - const values = this.colour_ + ';transparent;transparent;'; - this.markerSvgLine_.firstElementChild!.setAttribute('values', values); - this.markerInput_.firstElementChild!.setAttribute('values', values); - this.markerBlock_.firstElementChild!.setAttribute('values', values); - } - } - - /** Dispose of this marker. */ - dispose() { - if (this.svgGroup_) { - dom.removeNode(this.svgGroup_); - } - } -} diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index 0f46cf3a423..7efc6318a31 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -24,18 +24,6 @@ export class PathObject implements IPathObject { svgRoot: SVGElement; svgPath: SVGElement; - /** - * Holds the cursors svg element when the cursor is attached to the block. - * This is null if there is no cursor on the block. - */ - cursorSvg: SVGElement | null = null; - - /** - * Holds the markers svg element when the marker is attached to the block. - * This is null if there is no marker on the block. - */ - markerSvg: SVGElement | null = null; - constants: ConstantProvider; style: BlockStyle; @@ -62,9 +50,11 @@ export class PathObject implements IPathObject { /** The primary path of the block. */ this.svgPath = dom.createSvgElement( Svg.PATH, - {'class': 'blocklyPath'}, + {'class': 'blocklyPath', 'tabindex': '-1'}, this.svgRoot, ); + + this.setClass_('blocklyBlock', true); } /** @@ -84,42 +74,6 @@ export class PathObject implements IPathObject { this.svgPath.setAttribute('transform', 'scale(-1 1)'); } - /** - * Add the cursor SVG to this block's SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the block SVG - * group. - */ - setCursorSvg(cursorSvg: SVGElement) { - if (!cursorSvg) { - this.cursorSvg = null; - return; - } - - this.svgRoot.appendChild(cursorSvg); - this.cursorSvg = cursorSvg; - } - - /** - * Add the marker SVG to this block's SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the block SVG - * group. - */ - setMarkerSvg(markerSvg: SVGElement) { - if (!markerSvg) { - this.markerSvg = null; - return; - } - - if (this.cursorSvg) { - this.svgRoot.insertBefore(markerSvg, this.cursorSvg); - } else { - this.svgRoot.appendChild(markerSvg); - } - this.markerSvg = markerSvg; - } - /** * Apply the stored colours to the block's path, taking into account whether * the paths belong to a shadow block. @@ -167,14 +121,12 @@ export class PathObject implements IPathObject { * * @param enable True if highlighted. */ + updateHighlighted(enable: boolean) { if (enable) { - this.svgPath.setAttribute( - 'filter', - 'url(#' + this.constants.embossFilterId + ')', - ); + this.setClass_('blocklyHighlighted', true); } else { - this.svgPath.setAttribute('filter', 'none'); + this.setClass_('blocklyHighlighted', false); } } @@ -185,8 +137,11 @@ export class PathObject implements IPathObject { */ protected updateShadow_(shadow: boolean) { if (shadow) { + this.setClass_('blocklyShadow', true); this.svgPath.setAttribute('stroke', 'none'); this.svgPath.setAttribute('fill', this.style.colourSecondary); + } else { + this.setClass_('blocklyShadow', false); } } @@ -197,12 +152,7 @@ export class PathObject implements IPathObject { */ protected updateDisabled_(disabled: boolean) { this.setClass_('blocklyDisabled', disabled); - if (disabled) { - this.svgPath.setAttribute( - 'fill', - 'url(#' + this.constants.disabledPatternId + ')', - ); - } + this.setClass_('blocklyDisabledPattern', disabled); } /** @@ -270,37 +220,33 @@ export class PathObject implements IPathObject { connectionPath: string, offset: Coordinate, rtl: boolean, - ) { - if (this.connectionHighlights.has(connection)) { - if (this.currentHighlightMatchesNew(connection, connectionPath, offset)) { - return; - } - this.removeConnectionHighlight(connection); + ): SVGElement { + const transformation = + `translate(${offset.x}, ${offset.y})` + (rtl ? ' scale(-1 1)' : ''); + + const previousHighlight = this.connectionHighlights.get(connection); + if (previousHighlight) { + // Since a connection already exists, make sure that its path and + // transform are correct. + previousHighlight.setAttribute('d', connectionPath); + previousHighlight.setAttribute('transform', transformation); + return previousHighlight; } const highlight = dom.createSvgElement( Svg.PATH, { + 'id': connection.id, 'class': 'blocklyHighlightedConnectionPath', + 'style': 'display: none;', + 'tabindex': '-1', 'd': connectionPath, - 'transform': - `translate(${offset.x}, ${offset.y})` + (rtl ? ' scale(-1 1)' : ''), + 'transform': transformation, }, this.svgRoot, ); this.connectionHighlights.set(connection, highlight); - } - - private currentHighlightMatchesNew( - connection: RenderedConnection, - newPath: string, - newOffset: Coordinate, - ): boolean { - const currPath = this.connectionHighlights - .get(connection) - ?.getAttribute('d'); - const currOffset = this.highlightOffsets.get(connection); - return currPath === newPath && Coordinate.equals(currOffset, newOffset); + return highlight; } /** diff --git a/core/renderers/common/renderer.ts b/core/renderers/common/renderer.ts index d3bff56a702..5b7e687c25e 100644 --- a/core/renderers/common/renderer.ts +++ b/core/renderers/common/renderer.ts @@ -10,21 +10,12 @@ import type {Block} from '../../block.js'; import type {BlockSvg} from '../../block_svg.js'; import {Connection} from '../../connection.js'; import {ConnectionType} from '../../connection_type.js'; -import { - InsertionMarkerManager, - PreviewType, -} from '../../insertion_marker_manager.js'; import type {IRegistrable} from '../../interfaces/i_registrable.js'; -import type {Marker} from '../../keyboard_nav/marker.js'; -import type {RenderedConnection} from '../../rendered_connection.js'; import type {BlockStyle, Theme} from '../../theme.js'; -import * as deprecation from '../../utils/deprecation.js'; -import type {WorkspaceSvg} from '../../workspace_svg.js'; import {ConstantProvider} from './constants.js'; import {Drawer} from './drawer.js'; import type {IPathObject} from './i_path_object.js'; import {RenderInfo} from './info.js'; -import {MarkerSvg} from './marker_svg.js'; import {PathObject} from './path_object.js'; /** @@ -79,17 +70,27 @@ export class Renderer implements IRegistrable { /** * Create any DOM elements that this renderer needs. * If you need to create additional DOM elements, override the - * {@link ConstantProvider#createDom} method instead. + * {@link blockRendering#ConstantProvider.createDom} method instead. * * @param svg The root of the workspace's SVG. * @param theme The workspace theme object. + * @param injectionDivIfIsParent The div containing the parent workspace and + * all related workspaces and block containers, if this renderer is for the + * parent workspace. CSS variables representing SVG patterns will be scoped + * to this container. Child workspaces should not override the CSS variables + * created by the parent and thus do not need access to the injection div. * @internal */ - createDom(svg: SVGElement, theme: Theme) { + createDom( + svg: SVGElement, + theme: Theme, + injectionDivIfIsParent?: HTMLElement, + ) { this.constants_.createDom( svg, this.name + '-' + theme.name, '.' + this.getClassName() + '.' + theme.getClassName(), + injectionDivIfIsParent, ); } @@ -98,8 +99,17 @@ export class Renderer implements IRegistrable { * * @param svg The root of the workspace's SVG. * @param theme The workspace theme object. - */ - refreshDom(svg: SVGElement, theme: Theme) { + * @param injectionDivIfIsParent The div containing the parent workspace and + * all related workspaces and block containers, if this renderer is for the + * parent workspace. CSS variables representing SVG patterns will be scoped + * to this container. Child workspaces should not override the CSS variables + * created by the parent and thus do not need access to the injection div. + */ + refreshDom( + svg: SVGElement, + theme: Theme, + injectionDivIfIsParent?: HTMLElement, + ) { const previousConstants = this.getConstants(); previousConstants.dispose(); this.constants_ = this.makeConstants_(); @@ -110,7 +120,7 @@ export class Renderer implements IRegistrable { this.constants_.randomIdentifier = previousConstants.randomIdentifier; this.constants_.setTheme(theme); this.constants_.init(); - this.createDom(svg, theme); + this.createDom(svg, theme, injectionDivIfIsParent); } /** @@ -154,17 +164,6 @@ export class Renderer implements IRegistrable { return new Drawer(block, info); } - /** - * Create a new instance of the renderer's marker drawer. - * - * @param workspace The workspace the marker belongs to. - * @param marker The marker. - * @returns The object in charge of drawing the marker. - */ - makeMarkerDrawer(workspace: WorkspaceSvg, marker: Marker): MarkerSvg { - return new MarkerSvg(workspace, this.getConstants(), marker); - } - /** * Create a new instance of a renderer path object. * @@ -223,49 +222,6 @@ export class Renderer implements IRegistrable { ); } - /** - * Chooses a connection preview method based on the available connection, the - * current dragged connection, and the block being dragged. - * - * @param closest The available connection. - * @param local The connection currently being dragged. - * @param topBlock The block currently being dragged. - * @returns The preview type to display. - * - * @deprecated v10 - This function is no longer respected. A custom - * IConnectionPreviewer may be able to fulfill the functionality. - */ - getConnectionPreviewMethod( - closest: RenderedConnection, - local: RenderedConnection, - topBlock: BlockSvg, - ): PreviewType { - deprecation.warn( - 'getConnectionPreviewMethod', - 'v10', - 'v12', - 'an IConnectionPreviewer, if it fulfills your use case.', - ); - if ( - local.type === ConnectionType.OUTPUT_VALUE || - local.type === ConnectionType.PREVIOUS_STATEMENT - ) { - if ( - !closest.isConnected() || - this.orphanCanConnectAtEnd( - topBlock, - closest.targetBlock() as BlockSvg, - local.type, - ) - ) { - return InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER; - } - return InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE; - } - - return InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER; - } - /** * Render the block. * diff --git a/core/renderers/geras/drawer.ts b/core/renderers/geras/drawer.ts index 542b21ff93b..9d0ed829be7 100644 --- a/core/renderers/geras/drawer.ts +++ b/core/renderers/geras/drawer.ts @@ -100,7 +100,7 @@ export class Drawer extends BaseDrawer { } override drawInlineInput_(input: InlineInput) { - this.highlighter_.drawInlineInput(input as InlineInput); + this.highlighter_.drawInlineInput(input); super.drawInlineInput_(input); } diff --git a/core/renderers/geras/info.ts b/core/renderers/geras/info.ts index b9cc1c59c8c..11f9e764ac6 100644 --- a/core/renderers/geras/info.ts +++ b/core/renderers/geras/info.ts @@ -14,13 +14,9 @@ import {StatementInput} from '../../inputs/statement_input.js'; import {ValueInput} from '../../inputs/value_input.js'; import {RenderInfo as BaseRenderInfo} from '../common/info.js'; import type {Measurable} from '../measurables/base.js'; -import type {BottomRow} from '../measurables/bottom_row.js'; import {ExternalValueInput} from '../measurables/external_value_input.js'; -import type {Field} from '../measurables/field.js'; import {InRowSpacer} from '../measurables/in_row_spacer.js'; -import type {InputRow} from '../measurables/input_row.js'; import type {Row} from '../measurables/row.js'; -import type {TopRow} from '../measurables/top_row.js'; import {Types} from '../measurables/types.js'; import type {ConstantProvider} from './constants.js'; import {InlineInput} from './measurables/inline_input.js'; @@ -150,7 +146,7 @@ export class RenderInfo extends BaseRenderInfo { override getInRowSpacing_(prev: Measurable | null, next: Measurable | null) { if (!prev) { // Between an editable field and the beginning of the row. - if (next && Types.isField(next) && (next as Field).isEditable) { + if (next && Types.isField(next) && next.isEditable) { return this.constants_.MEDIUM_PADDING; } // Inline input at the beginning of the row. @@ -167,7 +163,10 @@ export class RenderInfo extends BaseRenderInfo { // Spacing between a non-input and the end of the row or a statement input. if (!Types.isInput(prev) && (!next || Types.isStatementInput(next))) { // Between an editable field and the end of the row. - if (Types.isField(prev) && (prev as Field).isEditable) { + if (Types.isField(prev) && prev.isEditable) { + if (prev.width === 0) { + return this.constants_.NO_PADDING; + } return this.constants_.MEDIUM_PADDING; } // Padding at the end of an icon-only row to make the block shape clearer. @@ -208,7 +207,7 @@ export class RenderInfo extends BaseRenderInfo { // Spacing between a non-input and an input. if (!Types.isInput(prev) && next && Types.isInput(next)) { // Between an editable field and an input. - if (Types.isField(prev) && (prev as Field).isEditable) { + if (Types.isField(prev) && prev.isEditable) { if (Types.isInlineInput(next)) { return this.constants_.SMALL_PADDING; } else if (Types.isExternalInput(next)) { @@ -233,7 +232,7 @@ export class RenderInfo extends BaseRenderInfo { // Spacing between an inline input and a field. if (Types.isInlineInput(prev) && next && Types.isField(next)) { // Editable field after inline input. - if ((next as Field).isEditable) { + if (next.isEditable) { return this.constants_.MEDIUM_PADDING; } else { // Noneditable field after inline input. @@ -278,8 +277,11 @@ export class RenderInfo extends BaseRenderInfo { Types.isField(prev) && next && Types.isField(next) && - (prev as Field).isEditable === (next as Field).isEditable + prev.isEditable === next.isEditable ) { + if (prev.width === 0) { + return this.constants_.NO_PADDING; + } return this.constants_.LARGE_PADDING; } @@ -323,20 +325,17 @@ export class RenderInfo extends BaseRenderInfo { return row.yPos + elem.height / 2; } if (Types.isBottomRow(row)) { - const bottomRow = row as BottomRow; - const baseline = - bottomRow.yPos + bottomRow.height - bottomRow.descenderHeight; + const baseline = row.yPos + row.height - row.descenderHeight; if (Types.isNextConnection(elem)) { return baseline + elem.height / 2; } return baseline - elem.height / 2; } if (Types.isTopRow(row)) { - const topRow = row as TopRow; if (Types.isHat(elem)) { - return topRow.capline - elem.height / 2; + return row.capline - elem.height / 2; } - return topRow.capline + elem.height / 2; + return row.capline + elem.height / 2; } let result = row.yPos; @@ -370,7 +369,7 @@ export class RenderInfo extends BaseRenderInfo { rowNextRightEdges.set(row, nextRightEdge); if (Types.isInputRow(row)) { if (row.hasStatement) { - this.alignStatementRow_(row as InputRow); + this.alignStatementRow_(row); } if ( prevInput && diff --git a/core/renderers/geras/path_object.ts b/core/renderers/geras/path_object.ts index c1d689535af..3b12fb13c08 100644 --- a/core/renderers/geras/path_object.ts +++ b/core/renderers/geras/path_object.ts @@ -102,14 +102,10 @@ export class PathObject extends BasePathObject { } override updateHighlighted(highlighted: boolean) { + super.updateHighlighted(highlighted); if (highlighted) { - this.svgPath.setAttribute( - 'filter', - 'url(#' + this.constants.embossFilterId + ')', - ); this.svgPathLight.style.display = 'none'; } else { - this.svgPath.setAttribute('filter', 'none'); this.svgPathLight.style.display = 'inline'; } } diff --git a/core/renderers/geras/renderer.ts b/core/renderers/geras/renderer.ts index aba8fc3eab1..ade8e5039d4 100644 --- a/core/renderers/geras/renderer.ts +++ b/core/renderers/geras/renderer.ts @@ -49,8 +49,12 @@ export class Renderer extends BaseRenderer { this.highlightConstants.init(); } - override refreshDom(svg: SVGElement, theme: Theme) { - super.refreshDom(svg, theme); + override refreshDom( + svg: SVGElement, + theme: Theme, + injectionDiv: HTMLElement, + ) { + super.refreshDom(svg, theme, injectionDiv); this.getHighlightConstants().init(); } diff --git a/core/renderers/measurables/in_row_spacer.ts b/core/renderers/measurables/in_row_spacer.ts index ec64e71a23a..d9378620cf7 100644 --- a/core/renderers/measurables/in_row_spacer.ts +++ b/core/renderers/measurables/in_row_spacer.ts @@ -15,6 +15,14 @@ import {Types} from './types.js'; * row. */ export class InRowSpacer extends Measurable { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private inRowSpacer: undefined; + /** * @param constants The rendering constants provider. * @param width The width of the spacer. diff --git a/core/renderers/measurables/input_row.ts b/core/renderers/measurables/input_row.ts index a9924246f38..869e6718f03 100644 --- a/core/renderers/measurables/input_row.ts +++ b/core/renderers/measurables/input_row.ts @@ -7,10 +7,7 @@ // Former goog.module ID: Blockly.blockRendering.InputRow import type {ConstantProvider} from '../common/constants.js'; -import {ExternalValueInput} from './external_value_input.js'; -import {InputConnection} from './input_connection.js'; import {Row} from './row.js'; -import {StatementInput} from './statement_input.js'; import {Types} from './types.js'; /** @@ -40,12 +37,11 @@ export class InputRow extends Row { for (let i = 0; i < this.elements.length; i++) { const elem = this.elements[i]; this.width += elem.width; - if (Types.isInput(elem) && elem instanceof InputConnection) { - if (Types.isStatementInput(elem) && elem instanceof StatementInput) { + if (Types.isInput(elem)) { + if (Types.isStatementInput(elem)) { connectedBlockWidths += elem.connectedBlockWidth; } else if ( Types.isExternalInput(elem) && - elem instanceof ExternalValueInput && elem.connectedBlockWidth !== 0 ) { connectedBlockWidths += diff --git a/core/renderers/measurables/jagged_edge.ts b/core/renderers/measurables/jagged_edge.ts index daca2512118..982e2b3530c 100644 --- a/core/renderers/measurables/jagged_edge.ts +++ b/core/renderers/measurables/jagged_edge.ts @@ -15,6 +15,14 @@ import {Types} from './types.js'; * collapsed block takes up during rendering. */ export class JaggedEdge extends Measurable { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private jaggedEdge: undefined; + /** * @param constants The rendering constants provider. */ diff --git a/core/renderers/measurables/next_connection.ts b/core/renderers/measurables/next_connection.ts index ea22001ed53..c10a26904bc 100644 --- a/core/renderers/measurables/next_connection.ts +++ b/core/renderers/measurables/next_connection.ts @@ -16,6 +16,14 @@ import {Types} from './types.js'; * up during rendering. */ export class NextConnection extends Connection { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private nextConnection: undefined; + /** * @param constants The rendering constants provider. * @param connectionModel The connection object on the block that this diff --git a/core/renderers/measurables/previous_connection.ts b/core/renderers/measurables/previous_connection.ts index 1314eb6a45d..30944766c48 100644 --- a/core/renderers/measurables/previous_connection.ts +++ b/core/renderers/measurables/previous_connection.ts @@ -16,6 +16,14 @@ import {Types} from './types.js'; * up during rendering. */ export class PreviousConnection extends Connection { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private previousConnection: undefined; + /** * @param constants The rendering constants provider. * @param connectionModel The connection object on the block that this diff --git a/core/renderers/measurables/round_corner.ts b/core/renderers/measurables/round_corner.ts index 60bbed70784..02c90546e1d 100644 --- a/core/renderers/measurables/round_corner.ts +++ b/core/renderers/measurables/round_corner.ts @@ -15,6 +15,14 @@ import {Types} from './types.js'; * during rendering. */ export class RoundCorner extends Measurable { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private roundCorner: undefined; + /** * @param constants The rendering constants provider. * @param opt_position The position of this corner. diff --git a/core/renderers/measurables/row.ts b/core/renderers/measurables/row.ts index 613ec6ace74..bc4707e83af 100644 --- a/core/renderers/measurables/row.ts +++ b/core/renderers/measurables/row.ts @@ -127,7 +127,7 @@ export class Row { for (let i = this.elements.length - 1; i >= 0; i--) { const elem = this.elements[i]; if (Types.isInput(elem)) { - return elem as InputConnection; + return elem; } } return null; @@ -166,8 +166,8 @@ export class Row { getFirstSpacer(): InRowSpacer | null { for (let i = 0; i < this.elements.length; i++) { const elem = this.elements[i]; - if (Types.isSpacer(elem)) { - return elem as InRowSpacer; + if (Types.isInRowSpacer(elem)) { + return elem; } } return null; @@ -181,8 +181,8 @@ export class Row { getLastSpacer(): InRowSpacer | null { for (let i = this.elements.length - 1; i >= 0; i--) { const elem = this.elements[i]; - if (Types.isSpacer(elem)) { - return elem as InRowSpacer; + if (Types.isInRowSpacer(elem)) { + return elem; } } return null; diff --git a/core/renderers/measurables/square_corner.ts b/core/renderers/measurables/square_corner.ts index 29749ac057d..054e148be23 100644 --- a/core/renderers/measurables/square_corner.ts +++ b/core/renderers/measurables/square_corner.ts @@ -15,6 +15,14 @@ import {Types} from './types.js'; * during rendering. */ export class SquareCorner extends Measurable { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private squareCorner: undefined; + /** * @param constants The rendering constants provider. * @param opt_position The position of this corner. diff --git a/core/renderers/measurables/statement_input.ts b/core/renderers/measurables/statement_input.ts index 91fe5b64a45..b0b527d36dd 100644 --- a/core/renderers/measurables/statement_input.ts +++ b/core/renderers/measurables/statement_input.ts @@ -16,6 +16,14 @@ import {Types} from './types.js'; * during rendering */ export class StatementInput extends InputConnection { + // This field exists solely to structurally distinguish this type from other + // Measurable subclasses. Because this class otherwise has the same fields as + // Measurable, and Typescript doesn't support nominal typing, Typescript will + // consider it and other subclasses in the same situation as being of the same + // type, even if typeguards are used, which could result in Typescript typing + // objects of this class as `never`. + private statementInput: undefined; + /** * @param constants The rendering constants provider. * @param input The statement input to measure and store information for. diff --git a/core/renderers/measurables/top_row.ts b/core/renderers/measurables/top_row.ts index b87ce4ad753..f1e7794806d 100644 --- a/core/renderers/measurables/top_row.ts +++ b/core/renderers/measurables/top_row.ts @@ -8,7 +8,6 @@ import type {BlockSvg} from '../../block_svg.js'; import type {ConstantProvider} from '../common/constants.js'; -import {Hat} from './hat.js'; import type {PreviousConnection} from './previous_connection.js'; import {Row} from './row.js'; import {Types} from './types.js'; @@ -85,7 +84,7 @@ export class TopRow extends Row { const elem = this.elements[i]; width += elem.width; if (!Types.isSpacer(elem)) { - if (Types.isHat(elem) && elem instanceof Hat) { + if (Types.isHat(elem)) { ascenderHeight = Math.max(ascenderHeight, elem.ascenderHeight); } else { height = Math.max(height, elem.height); diff --git a/core/renderers/measurables/types.ts b/core/renderers/measurables/types.ts index a145b156303..99de339f1b1 100644 --- a/core/renderers/measurables/types.ts +++ b/core/renderers/measurables/types.ts @@ -7,7 +7,24 @@ // Former goog.module ID: Blockly.blockRendering.Types import type {Measurable} from './base.js'; +import type {BottomRow} from './bottom_row.js'; +import type {ExternalValueInput} from './external_value_input.js'; +import type {Field} from './field.js'; +import type {Hat} from './hat.js'; +import type {Icon} from './icon.js'; +import type {InRowSpacer} from './in_row_spacer.js'; +import type {InlineInput} from './inline_input.js'; +import type {InputConnection} from './input_connection.js'; +import type {InputRow} from './input_row.js'; +import type {JaggedEdge} from './jagged_edge.js'; +import type {NextConnection} from './next_connection.js'; +import type {PreviousConnection} from './previous_connection.js'; +import type {RoundCorner} from './round_corner.js'; import type {Row} from './row.js'; +import type {SpacerRow} from './spacer_row.js'; +import type {SquareCorner} from './square_corner.js'; +import type {StatementInput} from './statement_input.js'; +import type {TopRow} from './top_row.js'; /** * Types of rendering elements. @@ -82,8 +99,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a field. */ - isField(elem: Measurable): number { - return elem.type & this.FIELD; + isField(elem: Measurable): elem is Field { + return (elem.type & this.FIELD) >= 1; } /** @@ -92,8 +109,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a hat. */ - isHat(elem: Measurable): number { - return elem.type & this.HAT; + isHat(elem: Measurable): elem is Hat { + return (elem.type & this.HAT) >= 1; } /** @@ -102,8 +119,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about an icon. */ - isIcon(elem: Measurable): number { - return elem.type & this.ICON; + isIcon(elem: Measurable): elem is Icon { + return (elem.type & this.ICON) >= 1; } /** @@ -112,8 +129,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a spacer. */ - isSpacer(elem: Measurable | Row): number { - return elem.type & this.SPACER; + isSpacer(elem: Measurable | Row): elem is SpacerRow | InRowSpacer { + return (elem.type & this.SPACER) >= 1; } /** @@ -122,8 +139,18 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about an in-row spacer. */ - isInRowSpacer(elem: Measurable): number { - return elem.type & this.IN_ROW_SPACER; + isInRowSpacer(elem: Measurable): elem is InRowSpacer { + return (elem.type & this.IN_ROW_SPACER) >= 1; + } + + /** + * Whether a row is a spacer row. + * + * @param row The row to check. + * @returns True if the row is a spacer row. + */ + isSpacerRow(row: Row): row is SpacerRow { + return (row.type & this.BETWEEN_ROW_SPACER) >= 1; } /** @@ -132,8 +159,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about an input. */ - isInput(elem: Measurable): number { - return elem.type & this.INPUT; + isInput(elem: Measurable): elem is InputConnection { + return (elem.type & this.INPUT) >= 1; } /** @@ -142,8 +169,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about an external input. */ - isExternalInput(elem: Measurable): number { - return elem.type & this.EXTERNAL_VALUE_INPUT; + isExternalInput(elem: Measurable): elem is ExternalValueInput { + return (elem.type & this.EXTERNAL_VALUE_INPUT) >= 1; } /** @@ -152,8 +179,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about an inline input. */ - isInlineInput(elem: Measurable): number { - return elem.type & this.INLINE_INPUT; + isInlineInput(elem: Measurable): elem is InlineInput { + return (elem.type & this.INLINE_INPUT) >= 1; } /** @@ -162,8 +189,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a statement input. */ - isStatementInput(elem: Measurable): number { - return elem.type & this.STATEMENT_INPUT; + isStatementInput(elem: Measurable): elem is StatementInput { + return (elem.type & this.STATEMENT_INPUT) >= 1; } /** @@ -172,8 +199,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a previous connection. */ - isPreviousConnection(elem: Measurable): number { - return elem.type & this.PREVIOUS_CONNECTION; + isPreviousConnection(elem: Measurable): elem is PreviousConnection { + return (elem.type & this.PREVIOUS_CONNECTION) >= 1; } /** @@ -182,8 +209,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a next connection. */ - isNextConnection(elem: Measurable): number { - return elem.type & this.NEXT_CONNECTION; + isNextConnection(elem: Measurable): elem is NextConnection { + return (elem.type & this.NEXT_CONNECTION) >= 1; } /** @@ -194,8 +221,17 @@ class TypesContainer { * @returns 1 if the object stores information about a previous or next * connection. */ - isPreviousOrNextConnection(elem: Measurable): number { - return elem.type & (this.PREVIOUS_CONNECTION | this.NEXT_CONNECTION); + isPreviousOrNextConnection( + elem: Measurable, + ): elem is PreviousConnection | NextConnection { + return this.isPreviousConnection(elem) || this.isNextConnection(elem); + } + + isRoundCorner(elem: Measurable): elem is RoundCorner { + return ( + (elem.type & this.LEFT_ROUND_CORNER) >= 1 || + (elem.type & this.RIGHT_ROUND_CORNER) >= 1 + ); } /** @@ -204,8 +240,10 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a left round corner. */ - isLeftRoundedCorner(elem: Measurable): number { - return elem.type & this.LEFT_ROUND_CORNER; + isLeftRoundedCorner(elem: Measurable): boolean { + return ( + this.isRoundCorner(elem) && (elem.type & this.LEFT_ROUND_CORNER) >= 1 + ); } /** @@ -214,8 +252,10 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a right round corner. */ - isRightRoundedCorner(elem: Measurable): number { - return elem.type & this.RIGHT_ROUND_CORNER; + isRightRoundedCorner(elem: Measurable): boolean { + return ( + this.isRoundCorner(elem) && (elem.type & this.RIGHT_ROUND_CORNER) >= 1 + ); } /** @@ -224,8 +264,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a left square corner. */ - isLeftSquareCorner(elem: Measurable): number { - return elem.type & this.LEFT_SQUARE_CORNER; + isLeftSquareCorner(elem: Measurable): boolean { + return (elem.type & this.LEFT_SQUARE_CORNER) >= 1; } /** @@ -234,8 +274,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a right square corner. */ - isRightSquareCorner(elem: Measurable): number { - return elem.type & this.RIGHT_SQUARE_CORNER; + isRightSquareCorner(elem: Measurable): boolean { + return (elem.type & this.RIGHT_SQUARE_CORNER) >= 1; } /** @@ -244,8 +284,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a corner. */ - isCorner(elem: Measurable): number { - return elem.type & this.CORNER; + isCorner(elem: Measurable): elem is SquareCorner | RoundCorner { + return (elem.type & this.CORNER) >= 1; } /** @@ -254,8 +294,8 @@ class TypesContainer { * @param elem The element to check. * @returns 1 if the object stores information about a jagged edge. */ - isJaggedEdge(elem: Measurable): number { - return elem.type & this.JAGGED_EDGE; + isJaggedEdge(elem: Measurable): elem is JaggedEdge { + return (elem.type & this.JAGGED_EDGE) >= 1; } /** @@ -264,8 +304,8 @@ class TypesContainer { * @param row The row to check. * @returns 1 if the object stores information about a row. */ - isRow(row: Row): number { - return row.type & this.ROW; + isRow(row: Row): row is Row { + return (row.type & this.ROW) >= 1; } /** @@ -274,8 +314,8 @@ class TypesContainer { * @param row The row to check. * @returns 1 if the object stores information about a between-row spacer. */ - isBetweenRowSpacer(row: Row): number { - return row.type & this.BETWEEN_ROW_SPACER; + isBetweenRowSpacer(row: Row): row is SpacerRow { + return (row.type & this.BETWEEN_ROW_SPACER) >= 1; } /** @@ -284,8 +324,8 @@ class TypesContainer { * @param row The row to check. * @returns 1 if the object stores information about a top row. */ - isTopRow(row: Row): number { - return row.type & this.TOP_ROW; + isTopRow(row: Row): row is TopRow { + return (row.type & this.TOP_ROW) >= 1; } /** @@ -294,8 +334,8 @@ class TypesContainer { * @param row The row to check. * @returns 1 if the object stores information about a bottom row. */ - isBottomRow(row: Row): number { - return row.type & this.BOTTOM_ROW; + isBottomRow(row: Row): row is BottomRow { + return (row.type & this.BOTTOM_ROW) >= 1; } /** @@ -304,8 +344,8 @@ class TypesContainer { * @param row The row to check. * @returns 1 if the object stores information about a top or bottom row. */ - isTopOrBottomRow(row: Row): number { - return row.type & (this.TOP_ROW | this.BOTTOM_ROW); + isTopOrBottomRow(row: Row): row is TopRow | BottomRow { + return this.isTopRow(row) || this.isBottomRow(row); } /** @@ -314,8 +354,8 @@ class TypesContainer { * @param row The row to check. * @returns 1 if the object stores information about an input row. */ - isInputRow(row: Row): number { - return row.type & this.INPUT_ROW; + isInputRow(row: Row): row is InputRow { + return (row.type & this.INPUT_ROW) >= 1; } } diff --git a/core/renderers/thrasos/info.ts b/core/renderers/thrasos/info.ts index 23772a9af0e..62c08fa424d 100644 --- a/core/renderers/thrasos/info.ts +++ b/core/renderers/thrasos/info.ts @@ -9,11 +9,8 @@ import type {BlockSvg} from '../../block_svg.js'; import {RenderInfo as BaseRenderInfo} from '../common/info.js'; import type {Measurable} from '../measurables/base.js'; -import type {BottomRow} from '../measurables/bottom_row.js'; -import type {Field} from '../measurables/field.js'; import {InRowSpacer} from '../measurables/in_row_spacer.js'; import type {Row} from '../measurables/row.js'; -import type {TopRow} from '../measurables/top_row.js'; import {Types} from '../measurables/types.js'; import type {Renderer} from './renderer.js'; @@ -94,7 +91,7 @@ export class RenderInfo extends BaseRenderInfo { override getInRowSpacing_(prev: Measurable | null, next: Measurable | null) { if (!prev) { // Between an editable field and the beginning of the row. - if (next && Types.isField(next) && (next as Field).isEditable) { + if (next && Types.isField(next) && next.isEditable) { return this.constants_.MEDIUM_PADDING; } // Inline input at the beginning of the row. @@ -111,7 +108,10 @@ export class RenderInfo extends BaseRenderInfo { // Spacing between a non-input and the end of the row. if (!Types.isInput(prev) && !next) { // Between an editable field and the end of the row. - if (Types.isField(prev) && (prev as Field).isEditable) { + if (Types.isField(prev) && prev.isEditable) { + if (prev.width === 0) { + return this.constants_.NO_PADDING; + } return this.constants_.MEDIUM_PADDING; } // Padding at the end of an icon-only row to make the block shape clearer. @@ -151,7 +151,7 @@ export class RenderInfo extends BaseRenderInfo { // Spacing between a non-input and an input. if (!Types.isInput(prev) && next && Types.isInput(next)) { // Between an editable field and an input. - if (Types.isField(prev) && (prev as Field).isEditable) { + if (Types.isField(prev) && prev.isEditable) { if (Types.isInlineInput(next)) { return this.constants_.SMALL_PADDING; } else if (Types.isExternalInput(next)) { @@ -177,7 +177,7 @@ export class RenderInfo extends BaseRenderInfo { // Spacing between an inline input and a field. if (Types.isInlineInput(prev) && next && Types.isField(next)) { // Editable field after inline input. - if ((next as Field).isEditable) { + if (next.isEditable) { return this.constants_.MEDIUM_PADDING; } else { // Noneditable field after inline input. @@ -205,8 +205,11 @@ export class RenderInfo extends BaseRenderInfo { Types.isField(prev) && next && Types.isField(next) && - (prev as Field).isEditable === (next as Field).isEditable + prev.isEditable === next.isEditable ) { + if (prev.width === 0) { + return this.constants_.NO_PADDING; + } return this.constants_.LARGE_PADDING; } @@ -247,20 +250,17 @@ export class RenderInfo extends BaseRenderInfo { return row.yPos + elem.height / 2; } if (Types.isBottomRow(row)) { - const bottomRow = row as BottomRow; - const baseline = - bottomRow.yPos + bottomRow.height - bottomRow.descenderHeight; + const baseline = row.yPos + row.height - row.descenderHeight; if (Types.isNextConnection(elem)) { return baseline + elem.height / 2; } return baseline - elem.height / 2; } if (Types.isTopRow(row)) { - const topRow = row as TopRow; if (Types.isHat(elem)) { - return topRow.capline - elem.height / 2; + return row.capline - elem.height / 2; } - return topRow.capline + elem.height / 2; + return row.capline + elem.height / 2; } let result = row.yPos; diff --git a/core/renderers/zelos/constants.ts b/core/renderers/zelos/constants.ts index afef605ebb3..8cd36e02589 100644 --- a/core/renderers/zelos/constants.ts +++ b/core/renderers/zelos/constants.ts @@ -151,9 +151,19 @@ export class ConstantProvider extends BaseConstantProvider { */ SQUARED: Shape | null = null; - constructor() { + /** + * Creates a new ConstantProvider. + * + * @param gridUnit If set, defines the base unit used to calculate other + * constants. + */ + constructor(gridUnit?: number) { super(); + if (gridUnit) { + this.GRID_UNIT = gridUnit; + } + this.SMALL_PADDING = this.GRID_UNIT; this.MEDIUM_PADDING = 2 * this.GRID_UNIT; @@ -290,7 +300,10 @@ export class ConstantProvider extends BaseConstantProvider { svgPaths.point(71, -height), svgPaths.point(width, 0), ]); - return {height, width, path: mainPath}; + // Height is actually the Y position of the control points defining the + // curve of the hat; the hat's actual rendered height is 3/4 of the control + // points' Y position, per https://stackoverflow.com/a/5327329 + return {height: height * 0.75, width, path: mainPath}; } /** @@ -662,8 +675,13 @@ export class ConstantProvider extends BaseConstantProvider { return utilsColour.blend('#000', colour, 0.25) || colour; } - override createDom(svg: SVGElement, tagName: string, selector: string) { - super.createDom(svg, tagName, selector); + override createDom( + svg: SVGElement, + tagName: string, + selector: string, + injectionDivIfIsParent?: HTMLElement, + ) { + super.createDom(svg, tagName, selector, injectionDivIfIsParent); /* ... filters go here ... @@ -782,6 +800,20 @@ export class ConstantProvider extends BaseConstantProvider { ); this.replacementGlowFilterId = replacementGlowFilter.id; this.replacementGlowFilter = replacementGlowFilter; + + if (injectionDivIfIsParent) { + // If this renderer is for the parent workspace, add CSS variables scoped + // to the injection div referencing the created patterns so that CSS can + // apply the patterns to any element in the injection div. + injectionDivIfIsParent.style.setProperty( + '--blocklySelectedGlowFilter', + `url(#${this.selectedGlowFilterId})`, + ); + injectionDivIfIsParent.style.setProperty( + '--blocklyReplacementGlowFilter', + `url(#${this.replacementGlowFilterId})`, + ); + } } override getCSS_(selector: string) { @@ -801,14 +833,14 @@ export class ConstantProvider extends BaseConstantProvider { `${selector} .blocklyText {`, `fill: #fff;`, `}`, - `${selector} .blocklyNonEditableText>rect:not(.blocklyDropdownRect),`, - `${selector} .blocklyEditableText>rect:not(.blocklyDropdownRect) {`, + `${selector} .blocklyNonEditableField>rect:not(.blocklyDropdownRect),`, + `${selector} .blocklyEditableField>rect:not(.blocklyDropdownRect) {`, `fill: ${this.FIELD_BORDER_RECT_COLOUR};`, `}`, - `${selector} .blocklyNonEditableText>text,`, - `${selector} .blocklyEditableText>text,`, - `${selector} .blocklyNonEditableText>g>text,`, - `${selector} .blocklyEditableText>g>text {`, + `${selector} .blocklyNonEditableField>text,`, + `${selector} .blocklyEditableField>text,`, + `${selector} .blocklyNonEditableField>g>text,`, + `${selector} .blocklyEditableField>g>text {`, `fill: #575E75;`, `}`, @@ -824,9 +856,9 @@ export class ConstantProvider extends BaseConstantProvider { // Editable field hover. `${selector} .blocklyDraggable:not(.blocklyDisabled)`, - ` .blocklyEditableText:not(.editing):hover>rect,`, + ` .blocklyEditableField:not(.blocklyEditing):hover>rect,`, `${selector} .blocklyDraggable:not(.blocklyDisabled)`, - ` .blocklyEditableText:not(.editing):hover>.blocklyPath {`, + ` .blocklyEditableField:not(.blocklyEditing):hover>.blocklyPath {`, `stroke: #fff;`, `stroke-width: 2;`, `}`, @@ -858,8 +890,8 @@ export class ConstantProvider extends BaseConstantProvider { `}`, // Disabled outline paths. - `${selector} .blocklyDisabled > .blocklyOutlinePath {`, - `fill: url(#blocklyDisabledPattern${this.randomIdentifier})`, + `${selector} .blocklyDisabledPattern > .blocklyOutlinePath {`, + `fill: var(--blocklyDisabledPattern)`, `}`, // Insertion marker. @@ -867,6 +899,15 @@ export class ConstantProvider extends BaseConstantProvider { `fill-opacity: ${this.INSERTION_MARKER_OPACITY};`, `stroke: none;`, `}`, + + `${selector} .blocklySelected>.blocklyPath.blocklyPathSelected {`, + `fill: none;`, + `filter: var(--blocklySelectedGlowFilter);`, + `}`, + + `${selector} .blocklyReplaceable>.blocklyPath {`, + `filter: var(--blocklyReplacementGlowFilter);`, + `}`, ]; } } diff --git a/core/renderers/zelos/drawer.ts b/core/renderers/zelos/drawer.ts index e5b91c1e607..b38711eb6c3 100644 --- a/core/renderers/zelos/drawer.ts +++ b/core/renderers/zelos/drawer.ts @@ -15,7 +15,6 @@ import {Connection} from '../measurables/connection.js'; import type {InlineInput} from '../measurables/inline_input.js'; import {OutputConnection} from '../measurables/output_connection.js'; import type {Row} from '../measurables/row.js'; -import type {SpacerRow} from '../measurables/spacer_row.js'; import {Types} from '../measurables/types.js'; import type {InsideCorners} from './constants.js'; import type {RenderInfo} from './info.js'; @@ -96,20 +95,19 @@ export class Drawer extends BaseDrawer { return; } if (Types.isSpacer(row)) { - const spacerRow = row as SpacerRow; - const precedesStatement = spacerRow.precedesStatement; - const followsStatement = spacerRow.followsStatement; + const precedesStatement = row.precedesStatement; + const followsStatement = row.followsStatement; if (precedesStatement || followsStatement) { const insideCorners = this.constants_.INSIDE_CORNERS as InsideCorners; const cornerHeight = insideCorners.rightHeight; const remainingHeight = - spacerRow.height - (precedesStatement ? cornerHeight : 0); + row.height - (precedesStatement ? cornerHeight : 0); const bottomRightPath = followsStatement ? insideCorners.pathBottomRight : ''; const verticalPath = remainingHeight > 0 - ? svgPaths.lineOnAxis('V', spacerRow.yPos + remainingHeight) + ? svgPaths.lineOnAxis('V', row.yPos + remainingHeight) : ''; const topRightPath = precedesStatement ? insideCorners.pathTopRight @@ -236,15 +234,16 @@ export class Drawer extends BaseDrawer { } /** Returns a path to highlight the given connection. */ - drawConnectionHighlightPath(measurable: Connection) { + override drawConnectionHighlightPath( + measurable: Connection, + ): SVGElement | undefined { const conn = measurable.connectionModel; if ( conn.type === ConnectionType.NEXT_STATEMENT || conn.type === ConnectionType.PREVIOUS_STATEMENT || (conn.type === ConnectionType.OUTPUT_VALUE && !measurable.isDynamicShape) ) { - super.drawConnectionHighlightPath(measurable); - return; + return super.drawConnectionHighlightPath(measurable); } let path = ''; @@ -263,7 +262,7 @@ export class Drawer extends BaseDrawer { (output.shape as DynamicShape).pathDown(output.height); } const block = conn.getSourceBlock(); - block.pathObject.addConnectionHighlight?.( + return block.pathObject.addConnectionHighlight?.( conn, path, conn.getOffsetInBlock(), diff --git a/core/renderers/zelos/info.ts b/core/renderers/zelos/info.ts index dd3702fe5d1..e14c584f0dc 100644 --- a/core/renderers/zelos/info.ts +++ b/core/renderers/zelos/info.ts @@ -20,7 +20,6 @@ import {RenderInfo as BaseRenderInfo} from '../common/info.js'; import type {Measurable} from '../measurables/base.js'; import {Field} from '../measurables/field.js'; import {InRowSpacer} from '../measurables/in_row_spacer.js'; -import {InputConnection} from '../measurables/input_connection.js'; import type {Row} from '../measurables/row.js'; import type {SpacerRow} from '../measurables/spacer_row.js'; import {Types} from '../measurables/types.js'; @@ -187,6 +186,12 @@ export class RenderInfo extends BaseRenderInfo { if (prev && Types.isLeftSquareCorner(prev) && next && Types.isHat(next)) { return this.constants_.NO_PADDING; } + + // No space after zero-width fields. + if (prev && Types.isField(prev) && prev.width === 0) { + return this.constants_.NO_PADDING; + } + return this.constants_.MEDIUM_PADDING; } @@ -207,9 +212,8 @@ export class RenderInfo extends BaseRenderInfo { } // Top and bottom rows act as a spacer so we don't need any extra padding. if (Types.isTopRow(prev)) { - const topRow = prev as TopRow; if ( - !topRow.hasPreviousConnection && + !prev.hasPreviousConnection && (!this.outputConnection || this.hasStatementInput) ) { return Math.abs( @@ -219,7 +223,6 @@ export class RenderInfo extends BaseRenderInfo { return this.constants_.NO_PADDING; } if (Types.isBottomRow(next)) { - const bottomRow = next as BottomRow; if (!this.outputConnection) { const topHeight = Math.max( @@ -230,7 +233,7 @@ export class RenderInfo extends BaseRenderInfo { ), ) - this.constants_.CORNER_RADIUS; return topHeight; - } else if (!bottomRow.hasNextConnection && this.hasStatementInput) { + } else if (!next.hasNextConnection && this.hasStatementInput) { return Math.abs( this.constants_.NOTCH_HEIGHT - this.constants_.CORNER_RADIUS, ); @@ -259,7 +262,7 @@ export class RenderInfo extends BaseRenderInfo { ) { return row.yPos + this.constants_.EMPTY_STATEMENT_INPUT_HEIGHT / 2; } - if (Types.isInlineInput(elem) && elem instanceof InputConnection) { + if (Types.isInlineInput(elem)) { const connectedBlock = elem.connectedBlock; if ( connectedBlock && @@ -308,7 +311,6 @@ export class RenderInfo extends BaseRenderInfo { } if ( Types.isField(elem) && - elem instanceof Field && elem.parentInput === this.rightAlignedDummyInputs.get(row) ) { break; @@ -371,7 +373,6 @@ export class RenderInfo extends BaseRenderInfo { xCursor < minXPos && !( Types.isField(elem) && - elem instanceof Field && (elem.field instanceof FieldLabel || elem.field instanceof FieldImage) ) @@ -525,7 +526,7 @@ export class RenderInfo extends BaseRenderInfo { return 0; } } - if (Types.isInlineInput(elem) && elem instanceof InputConnection) { + if (Types.isInlineInput(elem)) { const connectedBlock = elem.connectedBlock; const innerShape = connectedBlock ? (connectedBlock.pathObject as PathObject).outputShapeType @@ -552,7 +553,7 @@ export class RenderInfo extends BaseRenderInfo { connectionWidth - this.constants_.SHAPE_IN_SHAPE_PADDING[outerShape][innerShape] ); - } else if (Types.isField(elem) && elem instanceof Field) { + } else if (Types.isField(elem)) { // Special case for text inputs. if ( outerShape === constants.SHAPES.ROUND && @@ -616,7 +617,6 @@ export class RenderInfo extends BaseRenderInfo { for (let j = 0; j < row.elements.length; j++) { const elem = row.elements[j]; if ( - elem instanceof InputConnection && Types.isInlineInput(elem) && elem.connectedBlock && !elem.connectedBlock.isShadow() && diff --git a/core/renderers/zelos/marker_svg.ts b/core/renderers/zelos/marker_svg.ts deleted file mode 100644 index 395ece0b0be..00000000000 --- a/core/renderers/zelos/marker_svg.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// Former goog.module ID: Blockly.zelos.MarkerSvg - -import type {BlockSvg} from '../../block_svg.js'; -import type {ASTNode} from '../../keyboard_nav/ast_node.js'; -import type {Marker} from '../../keyboard_nav/marker.js'; -import type {RenderedConnection} from '../../rendered_connection.js'; -import * as dom from '../../utils/dom.js'; -import {Svg} from '../../utils/svg.js'; -import type {WorkspaceSvg} from '../../workspace_svg.js'; -import type {ConstantProvider as BaseConstantProvider} from '../common/constants.js'; -import {MarkerSvg as BaseMarkerSvg} from '../common/marker_svg.js'; -import type {ConstantProvider as ZelosConstantProvider} from './constants.js'; - -/** - * Class to draw a marker. - */ -export class MarkerSvg extends BaseMarkerSvg { - // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. - constants_!: ZelosConstantProvider; - - private markerCircle: SVGCircleElement | null = null; - - /** - * @param workspace The workspace the marker belongs to. - * @param constants The constants for the renderer. - * @param marker The marker to draw. - */ - constructor( - workspace: WorkspaceSvg, - constants: BaseConstantProvider, - marker: Marker, - ) { - super(workspace, constants, marker); - } - - /** - * Position and display the marker for an input or an output connection. - * - * @param curNode The node to draw the marker for. - */ - private showWithInputOutput(curNode: ASTNode) { - const block = curNode.getSourceBlock() as BlockSvg; - const connection = curNode.getLocation() as RenderedConnection; - const offsetInBlock = connection.getOffsetInBlock(); - - this.positionCircle(offsetInBlock.x, offsetInBlock.y); - this.setParent_(block); - this.showCurrent_(); - } - - override showWithOutput_(curNode: ASTNode) { - this.showWithInputOutput(curNode); - } - - override showWithInput_(curNode: ASTNode) { - this.showWithInputOutput(curNode); - } - - /** - * Draw a rectangle around the block. - * - * @param curNode The current node of the marker. - */ - override showWithBlock_(curNode: ASTNode) { - const block = curNode.getLocation() as BlockSvg; - - // Gets the height and width of entire stack. - const heightWidth = block.getHeightWidth(); - // Add padding so that being on a stack looks different than being on a - // block. - this.positionRect_(0, 0, heightWidth.width, heightWidth.height); - this.setParent_(block); - this.showCurrent_(); - } - - /** - * Position the circle we use for input and output connections. - * - * @param x The x position of the circle. - * @param y The y position of the circle. - */ - private positionCircle(x: number, y: number) { - this.markerCircle?.setAttribute('cx', `${x}`); - this.markerCircle?.setAttribute('cy', `${y}`); - this.currentMarkerSvg = this.markerCircle; - } - - override hide() { - super.hide(); - if (this.markerCircle) { - this.markerCircle.style.display = 'none'; - } - } - - override createDomInternal_() { - /* clang-format off */ - /* This markup will be generated and added to the .svgGroup_: - - - - - - */ - /* clang-format on */ - super.createDomInternal_(); - - this.markerCircle = dom.createSvgElement( - Svg.CIRCLE, - { - 'r': this.constants_.CURSOR_RADIUS, - 'stroke-width': this.constants_.CURSOR_STROKE_WIDTH, - }, - this.markerSvg_, - ); - this.hide(); - - // Markers and stack cursors don't blink. - if (this.isCursor()) { - const blinkProperties = this.getBlinkProperties_(); - dom.createSvgElement(Svg.ANIMATE, blinkProperties, this.markerCircle!); - } - - return this.markerSvg_!; - } - - override applyColour_(curNode: ASTNode) { - super.applyColour_(curNode); - - this.markerCircle?.setAttribute('fill', this.colour_); - this.markerCircle?.setAttribute('stroke', this.colour_); - - if (this.isCursor()) { - const values = this.colour_ + ';transparent;transparent;'; - this.markerCircle?.firstElementChild!.setAttribute('values', values); - } - } -} diff --git a/core/renderers/zelos/path_object.ts b/core/renderers/zelos/path_object.ts index a46d355b674..3c304fd6bf8 100644 --- a/core/renderers/zelos/path_object.ts +++ b/core/renderers/zelos/path_object.ts @@ -8,6 +8,7 @@ import type {BlockSvg} from '../../block_svg.js'; import type {Connection} from '../../connection.js'; +import {FocusManager} from '../../focus_manager.js'; import type {BlockStyle} from '../../theme.js'; import * as dom from '../../utils/dom.js'; import {Svg} from '../../utils/svg.js'; @@ -90,11 +91,18 @@ export class PathObject extends BasePathObject { if (enable) { if (!this.svgPathSelected) { this.svgPathSelected = this.svgPath.cloneNode(true) as SVGElement; - this.svgPathSelected.setAttribute('fill', 'none'); - this.svgPathSelected.setAttribute( - 'filter', - 'url(#' + this.constants.selectedGlowFilterId + ')', + this.svgPathSelected.classList.add('blocklyPathSelected'); + // Ensure focus-specific properties don't overlap with the block's path. + dom.removeClass( + this.svgPathSelected, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, ); + dom.removeClass( + this.svgPathSelected, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + this.svgPathSelected.removeAttribute('tabindex'); + this.svgPathSelected.removeAttribute('id'); this.svgRoot.appendChild(this.svgPathSelected); } } else { @@ -107,14 +115,6 @@ export class PathObject extends BasePathObject { override updateReplacementFade(enable: boolean) { this.setClass_('blocklyReplaceable', enable); - if (enable) { - this.svgPath.setAttribute( - 'filter', - 'url(#' + this.constants.replacementGlowFilterId + ')', - ); - } else { - this.svgPath.removeAttribute('filter'); - } } override updateShapeForInputHighlight(conn: Connection, enable: boolean) { @@ -173,10 +173,11 @@ export class PathObject extends BasePathObject { /** * Create's an outline path for the specified input. * + * @internal * @param name The input name. * @returns The SVG outline path. */ - private getOutlinePath(name: string): SVGElement { + getOutlinePath(name: string): SVGElement { if (!this.outlines.has(name)) { this.outlines.set( name, diff --git a/core/renderers/zelos/renderer.ts b/core/renderers/zelos/renderer.ts index b48600a0b4d..367d96faf51 100644 --- a/core/renderers/zelos/renderer.ts +++ b/core/renderers/zelos/renderer.ts @@ -7,20 +7,13 @@ // Former goog.module ID: Blockly.zelos.Renderer import type {BlockSvg} from '../../block_svg.js'; -import {ConnectionType} from '../../connection_type.js'; -import {InsertionMarkerManager} from '../../insertion_marker_manager.js'; -import type {Marker} from '../../keyboard_nav/marker.js'; -import type {RenderedConnection} from '../../rendered_connection.js'; import type {BlockStyle} from '../../theme.js'; -import * as deprecation from '../../utils/deprecation.js'; -import type {WorkspaceSvg} from '../../workspace_svg.js'; import * as blockRendering from '../common/block_rendering.js'; import type {RenderInfo as BaseRenderInfo} from '../common/info.js'; import {Renderer as BaseRenderer} from '../common/renderer.js'; import {ConstantProvider} from './constants.js'; import {Drawer} from './drawer.js'; import {RenderInfo} from './info.js'; -import {MarkerSvg} from './marker_svg.js'; import {PathObject} from './path_object.js'; /** @@ -73,20 +66,6 @@ export class Renderer extends BaseRenderer { return new Drawer(block, info as RenderInfo); } - /** - * Create a new instance of the renderer's cursor drawer. - * - * @param workspace The workspace the cursor belongs to. - * @param marker The marker. - * @returns The object in charge of drawing the marker. - */ - override makeMarkerDrawer( - workspace: WorkspaceSvg, - marker: Marker, - ): MarkerSvg { - return new MarkerSvg(workspace, this.getConstants(), marker); - } - /** * Create a new instance of a renderer path object. * @@ -107,36 +86,6 @@ export class Renderer extends BaseRenderer { override getConstants(): ConstantProvider { return this.constants_; } - - /** - * @deprecated v10 - This function is no longer respected. A custom - * IConnectionPreviewer may be able to fulfill the functionality. - */ - override getConnectionPreviewMethod( - closest: RenderedConnection, - local: RenderedConnection, - topBlock: BlockSvg, - ) { - deprecation.warn( - 'getConnectionPreviewMethod', - 'v10', - 'v12', - 'an IConnectionPreviewer, if it fulfills your use case.', - ); - if (local.type === ConnectionType.OUTPUT_VALUE) { - if (!closest.isConnected()) { - return InsertionMarkerManager.PREVIEW_TYPE.INPUT_OUTLINE; - } - // TODO: Returning this is a total hack, because we don't want to show - // a replacement fade, we want to show an outline affect. - // Sadly zelos does not support showing an outline around filled - // inputs, so we have to pretend like the connected block is getting - // replaced. - return InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE; - } - - return super.getConnectionPreviewMethod(closest, local, topBlock); - } } blockRendering.register('zelos', Renderer); diff --git a/core/renderers/zelos/zelos.ts b/core/renderers/zelos/zelos.ts index c28a0210c6d..5b0a7c51c60 100644 --- a/core/renderers/zelos/zelos.ts +++ b/core/renderers/zelos/zelos.ts @@ -11,7 +11,6 @@ import {ConstantProvider} from './constants.js'; import {Drawer} from './drawer.js'; import {RenderInfo} from './info.js'; -import {MarkerSvg} from './marker_svg.js'; import {BottomRow} from './measurables/bottom_row.js'; import {StatementInput} from './measurables/inputs.js'; import {RightConnectionShape} from './measurables/row_elements.js'; @@ -23,7 +22,6 @@ export { BottomRow, ConstantProvider, Drawer, - MarkerSvg, PathObject, Renderer, RenderInfo, diff --git a/core/separator_flyout_inflater.ts b/core/separator_flyout_inflater.ts new file mode 100644 index 00000000000..0c7897b0f03 --- /dev/null +++ b/core/separator_flyout_inflater.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlyoutItem} from './flyout_item.js'; +import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; +import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import * as registry from './registry.js'; +import type {SeparatorInfo} from './utils/toolbox.js'; + +/** + * @internal + */ +export const SEPARATOR_TYPE = 'sep'; + +/** + * Class responsible for creating separators for flyouts. + */ +export class SeparatorFlyoutInflater implements IFlyoutInflater { + /** + * Inflates a dummy flyout separator. + * + * The flyout automatically creates separators between every element with a + * size determined by calling gapForElement on the relevant inflater. + * Additionally, users can explicitly add separators in the flyout definition. + * When separators (implicitly or explicitly created) follow one another, the + * gap of the last one propagates backwards and flattens to one separator. + * This flattening is not additive; if there are initially separators of 2, 3, + * and 4 pixels, after normalization there will be one separator of 4 pixels. + * Therefore, this method returns a zero-width separator, which will be + * replaced by the one implicitly created by the flyout based on the value + * returned by gapForElement, which knows the default gap, unlike this method. + * + * @param _state A JSON representation of a flyout separator. + * @param flyout The flyout to create the separator for. + * @returns A newly created FlyoutSeparator. + */ + load(_state: object, flyout: IFlyout): FlyoutItem { + const flyoutAxis = flyout.horizontalLayout + ? SeparatorAxis.X + : SeparatorAxis.Y; + const separator = new FlyoutSeparator(0, flyoutAxis); + return new FlyoutItem(separator, SEPARATOR_TYPE); + } + + /** + * Returns the size of the separator. See `load` for more details. + * + * @param state A JSON representation of a flyout separator. + * @param defaultGap The default spacing for flyout items. + * @returns The desired size of the separator. + */ + gapForItem(state: object, defaultGap: number): number { + const separatorState = state as SeparatorInfo; + const newGap = parseInt(String(separatorState['gap'])); + return newGap ?? defaultGap; + } + + /** + * Disposes of the given separator. Intentional no-op. + * + * @param _item The flyout separator to dispose of. + */ + disposeItem(_item: FlyoutItem): void {} + + /** + * Returns the type of items this inflater is responsible for creating. + * + * @returns An identifier for the type of items this inflater creates. + */ + getType() { + return SEPARATOR_TYPE; + } +} + +registry.register( + registry.Type.FLYOUT_INFLATER, + SEPARATOR_TYPE, + SeparatorFlyoutInflater, +); diff --git a/core/serialization/blocks.ts b/core/serialization/blocks.ts index b9026224063..3696ab2f273 100644 --- a/core/serialization/blocks.ts +++ b/core/serialization/blocks.ts @@ -15,10 +15,13 @@ import * as eventUtils from '../events/utils.js'; import {inputTypes} from '../inputs/input_types.js'; import {isSerializable} from '../interfaces/i_serializable.js'; import type {ISerializer} from '../interfaces/i_serializer.js'; +import type { + IVariableModel, + IVariableState, +} from '../interfaces/i_variable_model.js'; import * as registry from '../registry.js'; import * as renderManagement from '../render_management.js'; import * as utilsXml from '../utils/xml.js'; -import {VariableModel} from '../variable_model.js'; import * as Variables from '../variables.js'; import type {Workspace} from '../workspace.js'; import * as Xml from '../xml.js'; @@ -32,6 +35,8 @@ import { import * as priorities from './priorities.js'; import * as serializationRegistry from './registry.js'; +// TODO(#5160): Remove this once lint is fixed. + /** * Represents the state of a connection. */ @@ -256,13 +261,9 @@ function saveIcons(block: Block, state: State, doFullSerialization: boolean) { */ function saveFields(block: Block, state: State, doFullSerialization: boolean) { const fields = Object.create(null); - for (let i = 0; i < block.inputList.length; i++) { - const input = block.inputList[i]; - for (let j = 0; j < input.fieldRow.length; j++) { - const field = input.fieldRow[j]; - if (field.isSerializable()) { - fields[field.name!] = field.saveState(doFullSerialization); - } + for (const field of block.getFields()) { + if (field.isSerializable()) { + fields[field.name!] = field.saveState(doFullSerialization); } } if (Object.keys(fields).length) { @@ -500,7 +501,7 @@ function appendPrivate( */ function checkNewVariables( workspace: Workspace, - originalVariables: VariableModel[], + originalVariables: IVariableModel[], ) { if (eventUtils.isEnabled()) { const newVariables = Variables.getAddedVariables( diff --git a/core/serialization/variables.ts b/core/serialization/variables.ts index e4fc7fbaab8..d9c266fb834 100644 --- a/core/serialization/variables.ts +++ b/core/serialization/variables.ts @@ -7,19 +7,12 @@ // Former goog.module ID: Blockly.serialization.variables import type {ISerializer} from '../interfaces/i_serializer.js'; +import type {IVariableState} from '../interfaces/i_variable_model.js'; +import * as registry from '../registry.js'; import type {Workspace} from '../workspace.js'; import * as priorities from './priorities.js'; import * as serializationRegistry from './registry.js'; -/** - * Represents the state of a given variable. - */ -export interface State { - name: string; - id: string; - type: string | undefined; -} - /** * Serializer for saving and loading variable state. */ @@ -38,23 +31,9 @@ export class VariableSerializer implements ISerializer { * @returns The state of the workspace's variables, or null if there are no * variables. */ - save(workspace: Workspace): State[] | null { - const variableStates = []; - for (const variable of workspace.getAllVariables()) { - const state = { - 'name': variable.name, - 'id': variable.getId(), - }; - if (variable.type) { - (state as AnyDuringMigration)['type'] = variable.type; - } - variableStates.push(state); - } - // AnyDuringMigration because: Type '{ name: string; id: string; }[] | - // null' is not assignable to type 'State[] | null'. - return ( - variableStates.length ? variableStates : null - ) as AnyDuringMigration; + save(workspace: Workspace): IVariableState[] | null { + const variableStates = workspace.getAllVariables().map((v) => v.save()); + return variableStates.length ? variableStates : null; } /** @@ -64,14 +43,14 @@ export class VariableSerializer implements ISerializer { * @param state The state of the variables to deserialize. * @param workspace The workspace to deserialize into. */ - load(state: State[], workspace: Workspace) { - for (const varState of state) { - workspace.createVariable( - varState['name'], - varState['type'], - varState['id'], - ); - } + load(state: IVariableState[], workspace: Workspace) { + const VariableModel = registry.getObject( + registry.Type.VARIABLE_MODEL, + registry.DEFAULT, + ); + state.forEach((s) => { + VariableModel?.load(s, workspace); + }); } /** diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 0db28a51a4b..a16e22aa33b 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -8,7 +8,6 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; -import * as common from './common.js'; import * as eventUtils from './events/utils.js'; import {Gesture} from './gesture.js'; import {ICopyData, isCopyable} from './interfaces/i_copyable.js'; @@ -40,12 +39,10 @@ export function registerEscape() { const escapeAction: KeyboardShortcut = { name: names.ESCAPE, preconditionFn(workspace) { - return !workspace.options.readOnly; + return !workspace.isReadOnly(); }, callback(workspace) { - // AnyDuringMigration because: Property 'hideChaff' does not exist on - // type 'Workspace'. - (workspace as AnyDuringMigration).hideChaff(); + workspace.hideChaff(); return true; }, keyCodes: [KeyCodes.ESC], @@ -59,28 +56,28 @@ export function registerEscape() { export function registerDelete() { const deleteShortcut: KeyboardShortcut = { name: names.DELETE, - preconditionFn(workspace) { - const selected = common.getSelected(); + preconditionFn(workspace, scope) { + const focused = scope.focusedNode; return ( - !workspace.options.readOnly && - selected != null && - isDeletable(selected) && - selected.isDeletable() && + !workspace.isReadOnly() && + focused != null && + isDeletable(focused) && + focused.isDeletable() && !Gesture.inProgress() ); }, - callback(workspace, e) { + callback(workspace, e, shortcut, scope) { // Delete or backspace. // Stop the browser from going back to the previous page. // Do this first to prevent an error in the delete code from resulting in // data loss. e.preventDefault(); - const selected = common.getSelected(); - if (selected instanceof BlockSvg) { - selected.checkAndDelete(); - } else if (isDeletable(selected) && selected.isDeletable()) { + const focused = scope.focusedNode; + if (focused instanceof BlockSvg) { + focused.checkAndDelete(); + } else if (isDeletable(focused) && focused.isDeletable()) { eventUtils.setGroup(true); - selected.dispose(); + focused.dispose(); eventUtils.setGroup(false); } return true; @@ -101,46 +98,43 @@ export function registerCopy() { const ctrlC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [ KeyCodes.CTRL, ]); - const altC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [ - KeyCodes.ALT, - ]); const metaC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [ KeyCodes.META, ]); const copyShortcut: KeyboardShortcut = { name: names.COPY, - preconditionFn(workspace) { - const selected = common.getSelected(); + preconditionFn(workspace, scope) { + const focused = scope.focusedNode; return ( - !workspace.options.readOnly && + !workspace.isReadOnly() && !Gesture.inProgress() && - selected != null && - isDeletable(selected) && - selected.isDeletable() && - isDraggable(selected) && - selected.isMovable() && - isCopyable(selected) + focused != null && + isDeletable(focused) && + focused.isDeletable() && + isDraggable(focused) && + focused.isMovable() && + isCopyable(focused) ); }, - callback(workspace, e) { + callback(workspace, e, shortcut, scope) { // Prevent the default copy behavior, which may beep or otherwise indicate // an error due to the lack of a selection. e.preventDefault(); workspace.hideChaff(); - const selected = common.getSelected(); - if (!selected || !isCopyable(selected)) return false; - copyData = selected.toCopyData(); + const focused = scope.focusedNode; + if (!focused || !isCopyable(focused)) return false; + copyData = focused.toCopyData(); copyWorkspace = - selected.workspace instanceof WorkspaceSvg - ? selected.workspace + focused.workspace instanceof WorkspaceSvg + ? focused.workspace : workspace; - copyCoords = isDraggable(selected) - ? selected.getRelativeToSurfaceXY() + copyCoords = isDraggable(focused) + ? focused.getRelativeToSurfaceXY() : null; return !!copyData; }, - keyCodes: [ctrlC, altC, metaC], + keyCodes: [ctrlC, metaC], }; ShortcutRegistry.registry.register(copyShortcut); } @@ -152,53 +146,51 @@ export function registerCut() { const ctrlX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [ KeyCodes.CTRL, ]); - const altX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [ - KeyCodes.ALT, - ]); const metaX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [ KeyCodes.META, ]); const cutShortcut: KeyboardShortcut = { name: names.CUT, - preconditionFn(workspace) { - const selected = common.getSelected(); + preconditionFn(workspace, scope) { + const focused = scope.focusedNode; return ( - !workspace.options.readOnly && + !workspace.isReadOnly() && !Gesture.inProgress() && - selected != null && - isDeletable(selected) && - selected.isDeletable() && - isDraggable(selected) && - selected.isMovable() && - !selected.workspace!.isFlyout + focused != null && + isDeletable(focused) && + focused.isDeletable() && + isDraggable(focused) && + focused.isMovable() && + isCopyable(focused) && + !focused.workspace.isFlyout ); }, - callback(workspace) { - const selected = common.getSelected(); + callback(workspace, e, shortcut, scope) { + const focused = scope.focusedNode; - if (selected instanceof BlockSvg) { - copyData = selected.toCopyData(); + if (focused instanceof BlockSvg) { + copyData = focused.toCopyData(); copyWorkspace = workspace; - copyCoords = selected.getRelativeToSurfaceXY(); - selected.checkAndDelete(); + copyCoords = focused.getRelativeToSurfaceXY(); + focused.checkAndDelete(); return true; } else if ( - isDeletable(selected) && - selected.isDeletable() && - isCopyable(selected) + isDeletable(focused) && + focused.isDeletable() && + isCopyable(focused) ) { - copyData = selected.toCopyData(); + copyData = focused.toCopyData(); copyWorkspace = workspace; - copyCoords = isDraggable(selected) - ? selected.getRelativeToSurfaceXY() + copyCoords = isDraggable(focused) + ? focused.getRelativeToSurfaceXY() : null; - selected.dispose(); + focused.dispose(); return true; } return false; }, - keyCodes: [ctrlX, altX, metaX], + keyCodes: [ctrlX, metaX], }; ShortcutRegistry.registry.register(cutShortcut); @@ -211,9 +203,6 @@ export function registerPaste() { const ctrlV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [ KeyCodes.CTRL, ]); - const altV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [ - KeyCodes.ALT, - ]); const metaV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [ KeyCodes.META, ]); @@ -221,7 +210,7 @@ export function registerPaste() { const pasteShortcut: KeyboardShortcut = { name: names.PASTE, preconditionFn(workspace) { - return !workspace.options.readOnly && !Gesture.inProgress(); + return !workspace.isReadOnly() && !Gesture.inProgress(); }, callback() { if (!copyData || !copyWorkspace) return false; @@ -246,7 +235,7 @@ export function registerPaste() { const centerCoords = new Coordinate(left + width / 2, top + height / 2); return !!clipboard.paste(copyData, copyWorkspace, centerCoords); }, - keyCodes: [ctrlV, altV, metaV], + keyCodes: [ctrlV, metaV], }; ShortcutRegistry.registry.register(pasteShortcut); @@ -259,9 +248,6 @@ export function registerUndo() { const ctrlZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ KeyCodes.CTRL, ]); - const altZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.ALT, - ]); const metaZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ KeyCodes.META, ]); @@ -269,7 +255,7 @@ export function registerUndo() { const undoShortcut: KeyboardShortcut = { name: names.UNDO, preconditionFn(workspace) { - return !workspace.options.readOnly && !Gesture.inProgress(); + return !workspace.isReadOnly() && !Gesture.inProgress(); }, callback(workspace, e) { // 'z' for undo 'Z' is for redo. @@ -278,7 +264,7 @@ export function registerUndo() { e.preventDefault(); return true; }, - keyCodes: [ctrlZ, altZ, metaZ], + keyCodes: [ctrlZ, metaZ], }; ShortcutRegistry.registry.register(undoShortcut); } @@ -292,10 +278,6 @@ export function registerRedo() { KeyCodes.SHIFT, KeyCodes.CTRL, ]); - const altShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.SHIFT, - KeyCodes.ALT, - ]); const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ KeyCodes.SHIFT, KeyCodes.META, @@ -308,7 +290,7 @@ export function registerRedo() { const redoShortcut: KeyboardShortcut = { name: names.REDO, preconditionFn(workspace) { - return !Gesture.inProgress() && !workspace.options.readOnly; + return !Gesture.inProgress() && !workspace.isReadOnly(); }, callback(workspace, e) { // 'z' for undo 'Z' is for redo. @@ -317,7 +299,7 @@ export function registerRedo() { e.preventDefault(); return true; }, - keyCodes: [ctrlShiftZ, altShiftZ, metaShiftZ, ctrlY], + keyCodes: [ctrlShiftZ, metaShiftZ, ctrlY], }; ShortcutRegistry.registry.register(redoShortcut); } diff --git a/core/shortcut_registry.ts b/core/shortcut_registry.ts index 09bd867e769..8a276c3d51c 100644 --- a/core/shortcut_registry.ts +++ b/core/shortcut_registry.ts @@ -12,6 +12,8 @@ */ // Former goog.module ID: Blockly.ShortcutRegistry +import {Scope} from './contextmenu_registry.js'; +import {getFocusManager} from './focus_manager.js'; import {KeyCodes} from './utils/keycodes.js'; import * as object from './utils/object.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -249,12 +251,21 @@ export class ShortcutRegistry { const shortcut = this.shortcuts.get(shortcutName); if ( !shortcut || - (shortcut.preconditionFn && !shortcut.preconditionFn(workspace)) + (shortcut.preconditionFn && + !shortcut.preconditionFn(workspace, { + focusedNode: getFocusManager().getFocusedNode() ?? undefined, + })) ) { continue; } // If the key has been handled, stop processing shortcuts. - if (shortcut.callback?.(workspace, e, shortcut)) return true; + if ( + shortcut.callback?.(workspace, e, shortcut, { + focusedNode: getFocusManager().getFocusedNode() ?? undefined, + }) + ) { + return true; + } } return false; } @@ -372,6 +383,8 @@ export namespace ShortcutRegistry { * @param e The event that caused the shortcut to be activated. * @param shortcut The `KeyboardShortcut` that was activated * (i.e., the one this callback is attached to). + * @param scope Information about the focused item when the + * shortcut was invoked. * @returns Returning true ends processing of the invoked keycode. * Returning false causes processing to continue with the * next-most-recently registered shortcut for the invoked @@ -381,6 +394,7 @@ export namespace ShortcutRegistry { workspace: WorkspaceSvg, e: Event, shortcut: KeyboardShortcut, + scope: Scope, ) => boolean; /** The name of the shortcut. Should be unique. */ @@ -393,9 +407,11 @@ export namespace ShortcutRegistry { * * @param workspace The `WorkspaceSvg` where the shortcut was * invoked. + * @param scope Information about the focused item when the + * shortcut would be invoked. * @returns True iff `callback` function should be called. */ - preconditionFn?: (workspace: WorkspaceSvg) => boolean; + preconditionFn?: (workspace: WorkspaceSvg, scope: Scope) => boolean; /** Optional arbitray extra data attached to the shortcut. */ metadata?: object; diff --git a/core/toast.ts b/core/toast.ts new file mode 100644 index 00000000000..72559279f57 --- /dev/null +++ b/core/toast.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Css from './css.js'; +import {Msg} from './msg.js'; +import * as aria from './utils/aria.js'; +import * as dom from './utils/dom.js'; +import {Svg} from './utils/svg.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +const CLASS_NAME = 'blocklyToast'; +const MESSAGE_CLASS_NAME = 'blocklyToastMessage'; +const CLOSE_BUTTON_CLASS_NAME = 'blocklyToastCloseButton'; + +/** + * Display/configuration options for a toast notification. + */ +export interface ToastOptions { + /** + * Toast ID. If set along with `oncePerSession`, will cause subsequent toasts + * with this ID to not be shown. + */ + id?: string; + + /** + * Flag to show the toast once per session only. + * Subsequent calls are ignored. + */ + oncePerSession?: boolean; + + /** + * Text of the message to display on the toast. + */ + message: string; + + /** + * Duration in seconds before the toast is removed. Defaults to 5. + */ + duration?: number; + + /** + * How prominently/interrupting the readout of the toast should be for + * screenreaders. Corresponds to aria-live and defaults to polite. + */ + assertiveness?: Toast.Assertiveness; +} + +/** + * Class that allows for showing and dismissing temporary notifications. + */ +export class Toast { + /** IDs of toasts that have previously been shown. */ + private static shownIds = new Set(); + + /** + * Shows a toast notification. + * + * @param workspace The workspace to show the toast on. + * @param options Configuration options for the toast message, duration, etc. + */ + static show(workspace: WorkspaceSvg, options: ToastOptions) { + if (options.oncePerSession && options.id) { + if (this.shownIds.has(options.id)) return; + this.shownIds.add(options.id); + } + + // Clear any existing toasts. + this.hide(workspace); + + const toast = this.createDom(workspace, options); + + // Animate the toast into view. + requestAnimationFrame(() => { + toast.style.bottom = '2rem'; + }); + } + + /** + * Creates the DOM representation of a toast. + * + * @param workspace The workspace to inject the toast notification onto. + * @param options Configuration options for the toast. + * @returns The root DOM element of the toast. + */ + protected static createDom(workspace: WorkspaceSvg, options: ToastOptions) { + const { + message, + duration = 5, + assertiveness = Toast.Assertiveness.POLITE, + } = options; + + const toast = document.createElement('div'); + workspace.getInjectionDiv().appendChild(toast); + toast.dataset.toastId = options.id; + toast.className = CLASS_NAME; + aria.setRole(toast, aria.Role.STATUS); + aria.setState(toast, aria.State.LIVE, assertiveness); + + const messageElement = toast.appendChild(document.createElement('div')); + messageElement.className = MESSAGE_CLASS_NAME; + messageElement.innerText = message; + const closeButton = toast.appendChild(document.createElement('button')); + closeButton.className = CLOSE_BUTTON_CLASS_NAME; + aria.setState(closeButton, aria.State.LABEL, Msg['CLOSE']); + const closeIcon = dom.createSvgElement( + Svg.SVG, + { + width: 24, + height: 24, + viewBox: '0 0 24 24', + fill: 'none', + }, + closeButton, + ); + aria.setState(closeIcon, aria.State.HIDDEN, true); + dom.createSvgElement( + Svg.RECT, + { + x: 19.7782, + y: 2.80762, + width: 2, + height: 24, + transform: 'rotate(45, 19.7782, 2.80762)', + fill: 'black', + }, + closeIcon, + ); + dom.createSvgElement( + Svg.RECT, + { + x: 2.80762, + y: 4.22183, + width: 2, + height: 24, + transform: 'rotate(-45, 2.80762, 4.22183)', + fill: 'black', + }, + closeIcon, + ); + closeButton.addEventListener('click', () => { + toast.remove(); + workspace.markFocused(); + }); + + let timeout: ReturnType; + const setToastTimeout = () => { + timeout = setTimeout(() => toast.remove(), duration * 1000); + }; + const clearToastTimeout = () => clearTimeout(timeout); + toast.addEventListener('focusin', clearToastTimeout); + toast.addEventListener('focusout', setToastTimeout); + toast.addEventListener('mouseenter', clearToastTimeout); + toast.addEventListener('mousemove', clearToastTimeout); + toast.addEventListener('mouseleave', setToastTimeout); + setToastTimeout(); + + return toast; + } + + /** + * Dismiss a toast, e.g. in response to a user action. + * + * @param workspace The workspace to dismiss a toast in. + * @param id The toast ID, or undefined to clear any toast. + */ + static hide(workspace: WorkspaceSvg, id?: string) { + const toast = workspace.getInjectionDiv().querySelector(`.${CLASS_NAME}`); + if (toast instanceof HTMLElement && (!id || id === toast.dataset.toastId)) { + toast.remove(); + } + } +} + +/** + * Options for how aggressively toasts should be read out by screenreaders. + * Values correspond to those for aria-live. + */ +export namespace Toast { + export enum Assertiveness { + ASSERTIVE = 'assertive', + POLITE = 'polite', + } +} + +Css.register(` +.${CLASS_NAME} { + font-size: 1.2rem; + position: absolute; + bottom: -10rem; + right: 2rem; + padding: 1rem; + color: black; + background-color: white; + border: 2px solid black; + border-radius: 0.4rem; + z-index: 999; + display: flex; + align-items: center; + gap: 0.8rem; + line-height: 1.5; + transition: bottom 0.3s ease-out; +} + +.${CLASS_NAME} .${MESSAGE_CLASS_NAME} { + maxWidth: 18rem; +} + +.${CLASS_NAME} .${CLOSE_BUTTON_CLASS_NAME} { + margin: 0; + padding: 0.2rem; + background-color: transparent; + color: black; + border: none; + cursor: pointer; +} +`); diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 1394f72187e..fc7d1aa03cf 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -130,15 +130,15 @@ export class ToolboxCategory */ protected makeDefaultCssConfig_(): CssConfig { return { - 'container': 'blocklyToolboxCategory', - 'row': 'blocklyTreeRow', + 'container': 'blocklyToolboxCategoryContainer', + 'row': 'blocklyToolboxCategory', 'rowcontentcontainer': 'blocklyTreeRowContentContainer', - 'icon': 'blocklyTreeIcon', - 'label': 'blocklyTreeLabel', - 'contents': 'blocklyToolboxContents', - 'selected': 'blocklyTreeSelected', - 'openicon': 'blocklyTreeIconOpen', - 'closedicon': 'blocklyTreeIconClosed', + 'icon': 'blocklyToolboxCategoryIcon', + 'label': 'blocklyToolboxCategoryLabel', + 'contents': 'blocklyToolboxCategoryGroup', + 'selected': 'blocklyToolboxSelected', + 'openicon': 'blocklyToolboxCategoryIconOpen', + 'closedicon': 'blocklyToolboxCategoryIconClosed', }; } @@ -225,6 +225,8 @@ export class ToolboxCategory */ protected createContainer_(): HTMLDivElement { const container = document.createElement('div'); + container.tabIndex = -1; + container.id = this.getId(); const className = this.cssConfig_['container']; if (className) { dom.addClass(container, className); @@ -662,19 +664,19 @@ export type CssConfig = ToolboxCategory.CssConfig; /** CSS for Toolbox. See css.js for use. */ Css.register(` -.blocklyTreeRow:not(.blocklyTreeSelected):hover { +.blocklyToolboxCategory:not(.blocklyToolboxSelected):hover { background-color: rgba(255, 255, 255, .2); } -.blocklyToolboxDiv[layout="h"] .blocklyToolboxCategory { +.blocklyToolbox[layout="h"] .blocklyToolboxCategoryContainer { margin: 1px 5px 1px 0; } -.blocklyToolboxDiv[dir="RTL"][layout="h"] .blocklyToolboxCategory { +.blocklyToolbox[dir="RTL"][layout="h"] .blocklyToolboxCategoryContainer { margin: 1px 0 1px 5px; } -.blocklyTreeRow { +.blocklyToolboxCategory { height: 22px; line-height: 22px; margin-bottom: 3px; @@ -682,12 +684,12 @@ Css.register(` white-space: nowrap; } -.blocklyToolboxDiv[dir="RTL"] .blocklyTreeRow { +.blocklyToolbox[dir="RTL"] .blocklyToolboxCategory { margin-left: 8px; padding-right: 0; } -.blocklyTreeIcon { +.blocklyToolboxCategoryIcon { background-image: url(<<>>/sprites.png); height: 16px; vertical-align: middle; @@ -695,42 +697,42 @@ Css.register(` width: 16px; } -.blocklyTreeIconClosed { +.blocklyToolboxCategoryIconClosed { background-position: -32px -1px; } -.blocklyToolboxDiv[dir="RTL"] .blocklyTreeIconClosed { +.blocklyToolbox[dir="RTL"] .blocklyToolboxCategoryIconClosed { background-position: 0 -1px; } -.blocklyTreeSelected>.blocklyTreeIconClosed { +.blocklyToolboxSelected>.blocklyToolboxCategoryIconClosed { background-position: -32px -17px; } -.blocklyToolboxDiv[dir="RTL"] .blocklyTreeSelected>.blocklyTreeIconClosed { +.blocklyToolbox[dir="RTL"] .blocklyToolboxSelected>.blocklyToolboxCategoryIconClosed { background-position: 0 -17px; } -.blocklyTreeIconOpen { +.blocklyToolboxCategoryIconOpen { background-position: -16px -1px; } -.blocklyTreeSelected>.blocklyTreeIconOpen { +.blocklyToolboxSelected>.blocklyToolboxCategoryIconOpen { background-position: -16px -17px; } -.blocklyTreeLabel { +.blocklyToolboxCategoryLabel { cursor: default; font: 16px sans-serif; padding: 0 3px; vertical-align: middle; } -.blocklyToolboxDelete .blocklyTreeLabel { +.blocklyToolboxDelete .blocklyToolboxCategoryLabel { cursor: url("<<>>/handdelete.cur"), auto; } -.blocklyTreeSelected .blocklyTreeLabel { +.blocklyToolboxSelected .blocklyToolboxCategoryLabel { color: #fff; } `); diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 59143642502..5048ff1269d 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -57,7 +57,7 @@ export class CollapsibleToolboxCategory override makeDefaultCssConfig_() { const cssConfig = super.makeDefaultCssConfig_(); - cssConfig['contents'] = 'blocklyToolboxContents'; + cssConfig['contents'] = 'blocklyToolboxCategoryGroup'; return cssConfig; } diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index 23874e42e79..44ae358cf53 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -54,6 +54,8 @@ export class ToolboxSeparator extends ToolboxItem { */ protected createDom_(): HTMLDivElement { const container = document.createElement('div'); + container.tabIndex = -1; + container.id = this.getId(); const className = this.cssConfig_['container']; if (className) { dom.addClass(container, className); @@ -87,7 +89,7 @@ Css.register(` margin: 5px 0; } -.blocklyToolboxDiv[layout="h"] .blocklyTreeSeparator { +.blocklyToolbox[layout="h"] .blocklyTreeSeparator { border-right: solid #e5e5e5 1px; border-bottom: none; height: auto; diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 8d4f4c9c8c8..0fbb231dc56 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -21,11 +21,14 @@ import {DeleteArea} from '../delete_area.js'; import '../events/events_toolbox_item_select.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import {getFocusManager} from '../focus_manager.js'; import type {IAutoHideable} from '../interfaces/i_autohideable.js'; import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFlyout} from '../interfaces/i_flyout.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IKeyboardAccessible} from '../interfaces/i_keyboard_accessible.js'; import type {ISelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.js'; import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.js'; @@ -49,7 +52,12 @@ import {CollapsibleToolboxCategory} from './collapsible_category.js'; */ export class Toolbox extends DeleteArea - implements IAutoHideable, IKeyboardAccessible, IStyleable, IToolbox + implements + IAutoHideable, + IKeyboardAccessible, + IStyleable, + IToolbox, + IFocusableNode { /** * The unique ID for this component that is used to register with the @@ -68,9 +76,6 @@ export class Toolbox /** Whether the Toolbox is visible. */ protected isVisible_ = false; - /** The list of items in the toolbox. */ - protected contents_: IToolboxItem[] = []; - /** The width of the toolbox. */ protected width_ = 0; @@ -80,7 +85,10 @@ export class Toolbox /** The flyout for the toolbox. */ private flyout: IFlyout | null = null; - protected contentMap_: {[key: string]: IToolboxItem}; + + /** Map from ID to the corresponding toolbox item. */ + protected contents = new Map(); + toolboxPosition: toolbox.Position; /** The currently selected item. */ @@ -116,9 +124,6 @@ export class Toolbox /** Is RTL vs LTR. */ this.RTL = workspace.options.RTL; - /** A map from toolbox item IDs to toolbox items. */ - this.contentMap_ = Object.create(null); - /** Position of the toolbox and flyout relative to the workspace. */ this.toolboxPosition = workspace.options.toolboxPosition; } @@ -141,7 +146,9 @@ export class Toolbox this.flyout = this.createFlyout_(); this.HtmlDiv = this.createDom_(this.workspace_); - dom.insertAfter(this.flyout.createDom('svg'), svg); + const flyoutDom = this.flyout.createDom('svg'); + dom.addClass(flyoutDom, 'blocklyToolboxFlyout'); + dom.insertAfter(flyoutDom, svg); this.setVisible(true); this.flyout.init(workspace); @@ -162,6 +169,7 @@ export class Toolbox ComponentManager.Capability.DRAG_TARGET, ], }); + getFocusManager().registerTree(this); } /** @@ -176,7 +184,6 @@ export class Toolbox const container = this.createContainer_(); this.contentsDiv_ = this.createContentsContainer_(); - this.contentsDiv_.tabIndex = 0; aria.setRole(this.contentsDiv_, aria.Role.TREE); container.appendChild(this.contentsDiv_); @@ -193,8 +200,9 @@ export class Toolbox */ protected createContainer_(): HTMLDivElement { const toolboxContainer = document.createElement('div'); + toolboxContainer.tabIndex = 0; toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v'); - dom.addClass(toolboxContainer, 'blocklyToolboxDiv'); + dom.addClass(toolboxContainer, 'blocklyToolbox'); toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR'); return toolboxContainer; } @@ -206,7 +214,7 @@ export class Toolbox */ protected createContentsContainer_(): HTMLDivElement { const contentsContainer = document.createElement('div'); - dom.addClass(contentsContainer, 'blocklyToolboxContents'); + dom.addClass(contentsContainer, 'blocklyToolboxCategoryGroup'); if (this.isHorizontal()) { contentsContainer.style.flexDirection = 'row'; } @@ -352,14 +360,8 @@ export class Toolbox */ render(toolboxDef: toolbox.ToolboxInfo) { this.toolboxDef_ = toolboxDef; - for (let i = 0; i < this.contents_.length; i++) { - const toolboxItem = this.contents_[i]; - if (toolboxItem) { - toolboxItem.dispose(); - } - } - this.contents_ = []; - this.contentMap_ = Object.create(null); + this.contents.forEach((item) => item.dispose()); + this.contents.clear(); this.renderContents_(toolboxDef['contents']); this.position(); this.handleToolboxItemResize(); @@ -430,8 +432,7 @@ export class Toolbox * @param toolboxItem The item in the toolbox. */ protected addToolboxItem_(toolboxItem: IToolboxItem) { - this.contents_.push(toolboxItem); - this.contentMap_[toolboxItem.getId()] = toolboxItem; + this.contents.set(toolboxItem.getId(), toolboxItem); if (toolboxItem.isCollapsible()) { const collapsibleItem = toolboxItem as ICollapsibleToolboxItem; const childToolboxItems = collapsibleItem.getChildToolboxItems(); @@ -448,7 +449,7 @@ export class Toolbox * @returns The list of items in the toolbox. */ getToolboxItems(): IToolboxItem[] { - return this.contents_; + return [...this.contents.values()]; } /** @@ -603,7 +604,7 @@ export class Toolbox * @returns The toolbox item with the given ID, or null if no item exists. */ getToolboxItemById(id: string): IToolboxItem | null { - return this.contentMap_[id] || null; + return this.contents.get(id) || null; } /** @@ -719,13 +720,18 @@ export class Toolbox // relative to the new absolute edge (ie toolbox edge). const workspace = this.workspace_; const rect = this.HtmlDiv!.getBoundingClientRect(); + const flyout = this.getFlyout(); const newX = this.toolboxPosition === toolbox.Position.LEFT - ? workspace.scrollX + rect.width + ? workspace.scrollX + + rect.width + + (flyout?.isVisible() ? flyout.getWidth() : 0) : workspace.scrollX; const newY = this.toolboxPosition === toolbox.Position.TOP - ? workspace.scrollY + rect.height + ? workspace.scrollY + + rect.height + + (flyout?.isVisible() ? flyout.getHeight() : 0) : workspace.scrollY; workspace.translate(newX, newY); @@ -745,14 +751,13 @@ export class Toolbox * @internal */ refreshTheme() { - for (let i = 0; i < this.contents_.length; i++) { - const child = this.contents_[i]; + this.contents.forEach((child) => { // TODO(#6097): Fix types or add refreshTheme to IToolboxItem. const childAsCategory = child as ToolboxCategory; if (childAsCategory.refreshTheme) { childAsCategory.refreshTheme(); } - } + }); } /** @@ -903,11 +908,9 @@ export class Toolbox * @param position The position of the item to select. */ selectItemByPosition(position: number) { - if (position > -1 && position < this.contents_.length) { - const item = this.contents_[position]; - if (item.isSelectable()) { - this.setSelectedItem(item); - } + const item = this.getToolboxItems()[position]; + if (item) { + this.setSelectedItem(item); } } @@ -1014,11 +1017,12 @@ export class Toolbox return false; } - let nextItemIdx = this.contents_.indexOf(this.selectedItem_) + 1; - if (nextItemIdx > -1 && nextItemIdx < this.contents_.length) { - let nextItem = this.contents_[nextItemIdx]; + const items = [...this.contents.values()]; + let nextItemIdx = items.indexOf(this.selectedItem_) + 1; + if (nextItemIdx > -1 && nextItemIdx < items.length) { + let nextItem = items[nextItemIdx]; while (nextItem && !nextItem.isSelectable()) { - nextItem = this.contents_[++nextItemIdx]; + nextItem = items[++nextItemIdx]; } if (nextItem && nextItem.isSelectable()) { this.setSelectedItem(nextItem); @@ -1038,11 +1042,12 @@ export class Toolbox return false; } - let prevItemIdx = this.contents_.indexOf(this.selectedItem_) - 1; - if (prevItemIdx > -1 && prevItemIdx < this.contents_.length) { - let prevItem = this.contents_[prevItemIdx]; + const items = [...this.contents.values()]; + let prevItemIdx = items.indexOf(this.selectedItem_) - 1; + if (prevItemIdx > -1 && prevItemIdx < items.length) { + let prevItem = items[prevItemIdx]; while (prevItem && !prevItem.isSelectable()) { - prevItem = this.contents_[--prevItemIdx]; + prevItem = items[--prevItemIdx]; } if (prevItem && prevItem.isSelectable()) { this.setSelectedItem(prevItem); @@ -1056,22 +1061,88 @@ export class Toolbox dispose() { this.workspace_.getComponentManager().removeComponent('toolbox'); this.flyout!.dispose(); - for (let i = 0; i < this.contents_.length; i++) { - const toolboxItem = this.contents_[i]; - toolboxItem.dispose(); - } + this.contents.forEach((item) => item.dispose()); for (let j = 0; j < this.boundEvents_.length; j++) { browserEvents.unbind(this.boundEvents_[j]); } this.boundEvents_ = []; - this.contents_ = []; + this.contents.clear(); if (this.HtmlDiv) { this.workspace_.getThemeManager().unsubscribe(this.HtmlDiv); dom.removeNode(this.HtmlDiv); } + + getFocusManager().unregisterTree(this); } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.HtmlDiv) throw Error('Toolbox DOM has not yet been created.'); + return this.HtmlDiv; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode( + previousNode: IFocusableNode | null, + ): IFocusableNode | null { + // Always try to select the first selectable toolbox item rather than the + // root of the toolbox. + if (!previousNode || previousNode === this) { + return this.getToolboxItems().find((item) => item.isSelectable()) ?? null; + } + return null; + } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): Array { + return []; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + lookUpFocusableNode(id: string): IFocusableNode | null { + return this.getToolboxItemById(id) as IFocusableNode; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus( + node: IFocusableNode, + _previousTree: IFocusableTree | null, + ): void { + if (node !== this) { + // Only select the item if it isn't already selected so as to not toggle. + if (this.getSelectedItem() !== node) { + this.setSelectedItem(node as IToolboxItem); + } + } else { + this.clearSelection(); + } + } + + /** See IFocusableTree.onTreeBlur. */ + onTreeBlur(_nextTree: IFocusableTree | null): void {} } /** CSS for Toolbox. See css.js for use. */ @@ -1087,7 +1158,10 @@ Css.register(` } /* Category tree in Toolbox. */ -.blocklyToolboxDiv { +.blocklyToolbox { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; background-color: #ddd; overflow-x: visible; overflow-y: auto; @@ -1097,13 +1171,13 @@ Css.register(` -webkit-tap-highlight-color: transparent; /* issue #1345 */ } -.blocklyToolboxContents { +.blocklyToolboxCategoryGroup { display: flex; flex-wrap: wrap; flex-direction: column; } -.blocklyToolboxContents:focus { +.blocklyToolboxCategoryGroup:focus { outline: none; } `); diff --git a/core/toolbox/toolbox_item.ts b/core/toolbox/toolbox_item.ts index ef9d979ab43..9fc5c160ddc 100644 --- a/core/toolbox/toolbox_item.ts +++ b/core/toolbox/toolbox_item.ts @@ -12,6 +12,7 @@ // Former goog.module ID: Blockly.ToolboxItem import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -148,5 +149,33 @@ export class ToolboxItem implements IToolboxItem { * @param _isVisible True if category should be visible. */ setVisible_(_isVisible: boolean) {} + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + const div = this.getDiv(); + if (!div) { + throw Error('Trying to access toolbox item before DOM is initialized.'); + } + if (!(div instanceof HTMLElement)) { + throw Error('Toolbox item div is unexpectedly not an HTML element.'); + } + return div as HTMLElement; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.parentToolbox_; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } } // nop by default diff --git a/core/trashcan.ts b/core/trashcan.ts index 0e80913f3ef..08211bac9dd 100644 --- a/core/trashcan.ts +++ b/core/trashcan.ts @@ -227,10 +227,9 @@ export class Trashcan /** Initializes the trash can. */ init() { if (this.workspace.options.maxTrashcanContents > 0) { - dom.insertAfter( - this.flyout!.createDom(Svg.SVG)!, - this.workspace.getParentSvg(), - ); + const flyoutDom = this.flyout!.createDom(Svg.SVG)!; + dom.addClass(flyoutDom, 'blocklyTrashcanFlyout'); + dom.insertAfter(flyoutDom, this.workspace.getParentSvg()); this.flyout!.init(this.workspace); } this.workspace.getComponentManager().addComponent({ diff --git a/core/utils/aria.ts b/core/utils/aria.ts index 567ea95ef73..d997b8d0af0 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -48,6 +48,12 @@ export enum Role { // ARIA role for a tree item that sometimes may be expanded or collapsed. TREEITEM = 'treeitem', + + // ARIA role for a visual separator in e.g. a menu. + SEPARATOR = 'separator', + + // ARIA role for a live region providing information. + STATUS = 'status', } /** @@ -107,6 +113,14 @@ export enum State { // ARIA property for slider minimum value. Value: number. VALUEMIN = 'valuemin', + + // ARIA property for live region chattiness. + // Value: one of {polite, assertive, off}. + LIVE = 'live', + + // ARIA property for removing elements from the accessibility tree. + // Value: one of {true, false, undefined}. + HIDDEN = 'hidden', } /** diff --git a/core/utils/dom.ts b/core/utils/dom.ts index 309cd3fae3b..4087984151c 100644 --- a/core/utils/dom.ts +++ b/core/utils/dom.ts @@ -208,16 +208,14 @@ export function getTextWidth(textElement: SVGTextElement): number { } } - // Attempt to compute fetch the width of the SVG text element. - try { - width = textElement.getComputedTextLength(); - } catch { - // In other cases where we fail to get the computed text. Instead, use an - // approximation and do not cache the result. At some later point in time - // when the block is inserted into the visible DOM, this method will be - // called again and, at that point in time, will not throw an exception. - return textElement.textContent!.length * 8; - } + // Compute the width of the SVG text element. + const style = window.getComputedStyle(textElement); + width = getFastTextWidthWithSizeString( + textElement, + style.fontSize, + style.fontWeight, + style.fontFamily, + ); // Cache the computed width and return. if (cacheWidths) { @@ -291,13 +289,13 @@ export function getFastTextWidthWithSizeString( // Initialize the HTML canvas context and set the font. // The context font must match blocklyText's fontsize and font-family // set in CSS. - canvasContext = computeCanvas.getContext('2d') as CanvasRenderingContext2D; + canvasContext = computeCanvas.getContext('2d'); } - // Set the desired font size and family. - canvasContext.font = fontWeight + ' ' + fontSize + ' ' + fontFamily; // Measure the text width using the helper canvas context. - if (text) { + if (text && canvasContext) { + // Set the desired font size and family. + canvasContext.font = fontWeight + ' ' + fontSize + ' ' + fontFamily; width = canvasContext.measureText(text).width; } else { width = 0; diff --git a/core/utils/focusable_tree_traverser.ts b/core/utils/focusable_tree_traverser.ts new file mode 100644 index 00000000000..916437b6a73 --- /dev/null +++ b/core/utils/focusable_tree_traverser.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import * as dom from '../utils/dom.js'; + +/** + * A helper utility for IFocusableTree implementations to aid with common + * tree traversals. + */ +export class FocusableTreeTraverser { + private static readonly ACTIVE_CLASS_NAME = 'blocklyActiveFocus'; + private static readonly PASSIVE_CSS_CLASS_NAME = 'blocklyPassiveFocus'; + private static readonly ACTIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusableTreeTraverser.ACTIVE_CLASS_NAME}`; + private static readonly PASSIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusableTreeTraverser.PASSIVE_CSS_CLASS_NAME}`; + + /** + * Returns the current IFocusableNode that is styled (and thus represented) as + * having either passive or active focus, only considering HTML and SVG + * elements. + * + * This can match against the tree's root. + * + * Note that this will never return a node from a nested sub-tree as that tree + * should specifically be used to retrieve its focused node. + * + * @param tree The IFocusableTree in which to search for a focused node. + * @returns The IFocusableNode currently with focus, or null if none. + */ + static findFocusedNode(tree: IFocusableTree): IFocusableNode | null { + const rootNode = tree.getRootFocusableNode(); + if (!rootNode.canBeFocused()) return null; + const root = rootNode.getFocusableElement(); + if ( + dom.hasClass(root, FocusableTreeTraverser.ACTIVE_CLASS_NAME) || + dom.hasClass(root, FocusableTreeTraverser.PASSIVE_CSS_CLASS_NAME) + ) { + // The root has focus. + return rootNode; + } + + const activeEl = root.querySelector(this.ACTIVE_FOCUS_NODE_CSS_SELECTOR); + if (activeEl instanceof HTMLElement || activeEl instanceof SVGElement) { + const active = FocusableTreeTraverser.findFocusableNodeFor( + activeEl, + tree, + ); + if (active) return active; + } + + // At most there should be one passive indicator per tree (not considering + // subtrees). + const passiveEl = root.querySelector(this.PASSIVE_FOCUS_NODE_CSS_SELECTOR); + if (passiveEl instanceof HTMLElement || passiveEl instanceof SVGElement) { + const passive = FocusableTreeTraverser.findFocusableNodeFor( + passiveEl, + tree, + ); + if (passive) return passive; + } + + return null; + } + + /** + * Returns the IFocusableNode corresponding to the specified HTML or SVG + * element iff it's the root element or a descendent of the root element of + * the specified IFocusableTree. + * + * If the element exists within the specified tree's DOM structure but does + * not directly correspond to a node, the nearest parent node (or the tree's + * root) will be returned to represent the provided element. + * + * If the tree contains another nested IFocusableTree, the nested tree may be + * traversed but its nodes will never be returned here per the contract of + * IFocusableTree.lookUpFocusableNode. + * + * The provided element must have a non-null ID that conforms to the contract + * mentioned in IFocusableNode. + * + * @param element The HTML or SVG element being sought. + * @param tree The tree under which the provided element may be a descendant. + * @returns The matching IFocusableNode, or null if there is no match. + */ + static findFocusableNodeFor( + element: HTMLElement | SVGElement, + tree: IFocusableTree, + ): IFocusableNode | null { + // First, match against subtrees. + const subTreeMatches = tree.getNestedTrees().map((tree) => { + return FocusableTreeTraverser.findFocusableNodeFor(element, tree); + }); + if (subTreeMatches.findIndex((match) => !!match) !== -1) { + // At least one subtree has a match for the element so it cannot be part + // of the outer tree. + return null; + } + + // Second, check against the tree's root. + const rootNode = tree.getRootFocusableNode(); + if (rootNode.canBeFocused() && element === rootNode.getFocusableElement()) { + return rootNode; + } + + // Third, check if the element has a node. + const matchedChildNode = tree.lookUpFocusableNode(element.id) ?? null; + if (matchedChildNode) return matchedChildNode; + + // Fourth, recurse up to find the nearest tree/node if it's possible. + const elementParent = element.parentElement; + if (!matchedChildNode && elementParent) { + return FocusableTreeTraverser.findFocusableNodeFor(elementParent, tree); + } + + // Otherwise, there's no matching node. + return null; + } +} diff --git a/core/utils/object.ts b/core/utils/object.ts index 6aaabcc90f1..5cf0b49f2b9 100644 --- a/core/utils/object.ts +++ b/core/utils/object.ts @@ -9,6 +9,9 @@ /** * Complete a deep merge of all members of a source object with a target object. * + * N.B. This is not a very sophisticated merge algorithm and does not + * handle complex cases. Use with caution. + * * @param target Target. * @param source Source. * @returns The resulting object. @@ -18,7 +21,9 @@ export function deepMerge( source: AnyDuringMigration, ): AnyDuringMigration { for (const x in source) { - if (source[x] !== null && typeof source[x] === 'object') { + if (source[x] !== null && Array.isArray(source[x])) { + target[x] = deepMerge(target[x] || [], source[x]); + } else if (source[x] !== null && typeof source[x] === 'object') { target[x] = deepMerge(target[x] || Object.create(null), source[x]); } else { target[x] = source[x]; diff --git a/core/utils/toolbox.ts b/core/utils/toolbox.ts index 296bb6dcc94..f81ebdc72ca 100644 --- a/core/utils/toolbox.ts +++ b/core/utils/toolbox.ts @@ -24,8 +24,6 @@ export interface BlockInfo { disabledReasons?: string[]; enabled?: boolean; id?: string; - x?: number; - y?: number; collapsed?: boolean; inline?: boolean; data?: string; diff --git a/core/variable_map.ts b/core/variable_map.ts index b28e8a3550e..403893332e5 100644 --- a/core/variable_map.ts +++ b/core/variable_map.ts @@ -17,14 +17,15 @@ import './events/events_var_delete.js'; import './events/events_var_rename.js'; import type {Block} from './block.js'; -import * as dialog from './dialog.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; -import {Msg} from './msg.js'; +import type {IVariableMap} from './interfaces/i_variable_map.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import {Names} from './names.js'; -import * as arrayUtils from './utils/array.js'; +import * as registry from './registry.js'; +import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; -import {VariableModel} from './variable_model.js'; +import {deleteVariable, getVariableUsesById} from './variables.js'; import type {Workspace} from './workspace.js'; /** @@ -32,22 +33,34 @@ import type {Workspace} from './workspace.js'; * variable types as keys and lists of variables as values. The list of * variables are the type indicated by the key. */ -export class VariableMap { +export class VariableMap + implements IVariableMap> +{ /** - * A map from variable type to list of variable names. The lists contain + * A map from variable type to map of IDs to variables. The maps contain * all of the named variables in the workspace, including variables that are * not currently in use. */ - private variableMap = new Map(); + private variableMap = new Map< + string, + Map> + >(); - /** @param workspace The workspace this map belongs to. */ - constructor(public workspace: Workspace) {} + /** + * @param workspace The workspace this map belongs to. + * @param potentialMap True if this holds variables that don't exist in the + * workspace yet. + */ + constructor( + public workspace: Workspace, + public potentialMap = false, + ) {} /** Clear the variable map. Fires events for every deletion. */ clear() { for (const variables of this.variableMap.values()) { - while (variables.length > 0) { - this.deleteVariable(variables[0]); + for (const variable of variables.values()) { + this.deleteVariable(variable); } } if (this.variableMap.size !== 0) { @@ -61,16 +74,22 @@ export class VariableMap { * * @param variable Variable to rename. * @param newName New variable name. - * @internal + * @returns The newly renamed variable. */ - renameVariable(variable: VariableModel, newName: string) { - if (variable.name === newName) return; - const type = variable.type; + renameVariable( + variable: IVariableModel, + newName: string, + ): IVariableModel { + if (variable.getName() === newName) return variable; + const type = variable.getType(); const conflictVar = this.getVariable(newName, type); const blocks = this.workspace.getAllBlocks(false); - const existingGroup = eventUtils.getGroup(); - if (!existingGroup) { - eventUtils.setGroup(true); + let existingGroup = ''; + if (!this.potentialMap) { + existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } } try { // The IDs may match if the rename is a simple case change (name1 -> @@ -81,18 +100,42 @@ export class VariableMap { this.renameVariableWithConflict(variable, newName, conflictVar, blocks); } } finally { - eventUtils.setGroup(existingGroup); + if (!this.potentialMap) eventUtils.setGroup(existingGroup); } + return variable; + } + + changeVariableType( + variable: IVariableModel, + newType: string, + ): IVariableModel { + this.variableMap.get(variable.getType())?.delete(variable.getId()); + variable.setType(newType); + const newTypeVariables = + this.variableMap.get(newType) ?? + new Map>(); + newTypeVariables.set(variable.getId(), variable); + if (!this.variableMap.has(newType)) { + this.variableMap.set(newType, newTypeVariables); + } + return variable; } /** * Rename a variable by updating its name in the variable map. Identify the * variable to rename with the given ID. * + * @deprecated v12: use VariableMap.renameVariable. * @param id ID of the variable to rename. * @param newName New variable name. */ renameVariableById(id: string, newName: string) { + deprecation.warn( + 'VariableMap.renameVariableById', + 'v12', + 'v13', + 'VariableMap.renameVariable', + ); const variable = this.getVariableById(id); if (!variable) { throw Error("Tried to rename a variable that didn't exist. ID: " + id); @@ -110,14 +153,16 @@ export class VariableMap { * @param blocks The list of all blocks in the workspace. */ private renameVariableAndUses( - variable: VariableModel, + variable: IVariableModel, newName: string, blocks: Block[], ) { - eventUtils.fire( - new (eventUtils.get(EventType.VAR_RENAME))(variable, newName), - ); - variable.name = newName; + if (!this.potentialMap) { + eventUtils.fire( + new (eventUtils.get(EventType.VAR_RENAME))(variable, newName), + ); + } + variable.setName(newName); for (let i = 0; i < blocks.length; i++) { blocks[i].updateVarName(variable); } @@ -135,13 +180,13 @@ export class VariableMap { * @param blocks The list of all blocks in the workspace. */ private renameVariableWithConflict( - variable: VariableModel, + variable: IVariableModel, newName: string, - conflictVar: VariableModel, + conflictVar: IVariableModel, blocks: Block[], ) { - const type = variable.type; - const oldCase = conflictVar.name; + const type = variable.getType(); + const oldCase = conflictVar.getName(); if (newName !== oldCase) { // Simple rename to change the case and update references. @@ -153,10 +198,12 @@ export class VariableMap { for (let i = 0; i < blocks.length; i++) { blocks[i].renameVarById(variable.getId(), conflictVar.getId()); } - // Finally delete the original variable, which is now unreferenced. - eventUtils.fire(new (eventUtils.get(EventType.VAR_DELETE))(variable)); - // And remove it from the list. - arrayUtils.removeElem(this.variableMap.get(type)!, variable); + if (!this.potentialMap) { + // Finally delete the original variable, which is now unreferenced. + eventUtils.fire(new (eventUtils.get(EventType.VAR_DELETE))(variable)); + } + // And remove it from the map. + this.variableMap.get(type)?.delete(variable.getId()); } /* End functions for renaming variables. */ @@ -173,9 +220,9 @@ export class VariableMap { */ createVariable( name: string, - opt_type?: string | null, - opt_id?: string | null, - ): VariableModel { + opt_type?: string, + opt_id?: string, + ): IVariableModel { let variable = this.getVariable(name, opt_type); if (variable) { if (opt_id && variable.getId() !== opt_id) { @@ -198,42 +245,76 @@ export class VariableMap { } const id = opt_id || idGenerator.genUid(); const type = opt_type || ''; + const VariableModel = registry.getObject( + registry.Type.VARIABLE_MODEL, + registry.DEFAULT, + true, + ); + if (!VariableModel) { + throw new Error('No variable model is registered.'); + } variable = new VariableModel(this.workspace, name, type, id); - const variables = this.variableMap.get(type) || []; - variables.push(variable); - // Delete the list of variables of this type, and re-add it so that - // the most recent addition is at the end. - // This is used so the toolbox's set block is set to the most recent - // variable. - this.variableMap.delete(type); - this.variableMap.set(type, variables); - - eventUtils.fire(new (eventUtils.get(EventType.VAR_CREATE))(variable)); - + const variables = + this.variableMap.get(type) ?? + new Map>(); + variables.set(variable.getId(), variable); + if (!this.variableMap.has(type)) { + this.variableMap.set(type, variables); + } + if (!this.potentialMap) { + eventUtils.fire(new (eventUtils.get(EventType.VAR_CREATE))(variable)); + } return variable; } + /** + * Adds the given variable to this variable map. + * + * @param variable The variable to add. + */ + addVariable(variable: IVariableModel) { + const type = variable.getType(); + if (!this.variableMap.has(type)) { + this.variableMap.set( + type, + new Map>(), + ); + } + this.variableMap.get(type)?.set(variable.getId(), variable); + } + /* Begin functions for variable deletion. */ /** - * Delete a variable. + * Delete a variable and all of its uses without confirmation. * * @param variable Variable to delete. */ - deleteVariable(variable: VariableModel) { - const variableId = variable.getId(); - const variableList = this.variableMap.get(variable.type); - if (variableList) { - for (let i = 0; i < variableList.length; i++) { - const tempVar = variableList[i]; - if (tempVar.getId() === variableId) { - variableList.splice(i, 1); - eventUtils.fire(new (eventUtils.get(EventType.VAR_DELETE))(variable)); - if (variableList.length === 0) { - this.variableMap.delete(variable.type); - } - return; - } + deleteVariable(variable: IVariableModel) { + const uses = getVariableUsesById(this.workspace, variable.getId()); + let existingGroup = ''; + if (!this.potentialMap) { + existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + } + try { + for (let i = 0; i < uses.length; i++) { + uses[i].dispose(true); + } + const variables = this.variableMap.get(variable.getType()); + if (!variables || !variables.has(variable.getId())) return; + variables.delete(variable.getId()); + if (!this.potentialMap) { + eventUtils.fire(new (eventUtils.get(EventType.VAR_DELETE))(variable)); + } + if (variables.size === 0) { + this.variableMap.delete(variable.getType()); + } + } finally { + if (!this.potentialMap) { + eventUtils.setGroup(existingGroup); } } } @@ -242,69 +323,22 @@ export class VariableMap { * Delete a variables by the passed in ID and all of its uses from this * workspace. May prompt the user for confirmation. * + * @deprecated v12: use Blockly.Variables.deleteVariable. * @param id ID of variable to delete. */ deleteVariableById(id: string) { + deprecation.warn( + 'VariableMap.deleteVariableById', + 'v12', + 'v13', + 'Blockly.Variables.deleteVariable', + ); const variable = this.getVariableById(id); if (variable) { - // Check whether this variable is a function parameter before deleting. - const variableName = variable.name; - const uses = this.getVariableUsesById(id); - for (let i = 0, block; (block = uses[i]); i++) { - if ( - block.type === 'procedures_defnoreturn' || - block.type === 'procedures_defreturn' - ) { - const procedureName = String(block.getFieldValue('NAME')); - const deleteText = Msg['CANNOT_DELETE_VARIABLE_PROCEDURE'] - .replace('%1', variableName) - .replace('%2', procedureName); - dialog.alert(deleteText); - return; - } - } - - if (uses.length > 1) { - // Confirm before deleting multiple blocks. - const confirmText = Msg['DELETE_VARIABLE_CONFIRMATION'] - .replace('%1', String(uses.length)) - .replace('%2', variableName); - dialog.confirm(confirmText, (ok) => { - if (ok && variable) { - this.deleteVariableInternal(variable, uses); - } - }); - } else { - // No confirmation necessary for a single block. - this.deleteVariableInternal(variable, uses); - } - } else { - console.warn("Can't delete non-existent variable: " + id); + deleteVariable(this.workspace, variable); } } - /** - * Deletes a variable and all of its uses from this workspace without asking - * the user for confirmation. - * - * @param variable Variable to delete. - * @param uses An array of uses of the variable. - * @internal - */ - deleteVariableInternal(variable: VariableModel, uses: Block[]) { - const existingGroup = eventUtils.getGroup(); - if (!existingGroup) { - eventUtils.setGroup(true); - } - try { - for (let i = 0; i < uses.length; i++) { - uses[i].dispose(true); - } - this.deleteVariable(variable); - } finally { - eventUtils.setGroup(existingGroup); - } - } /* End functions for variable deletion. */ /** * Find the variable by the given name and type and return it. Return null if @@ -315,17 +349,19 @@ export class VariableMap { * the empty string, which is a specific type. * @returns The variable with the given name, or null if it was not found. */ - getVariable(name: string, opt_type?: string | null): VariableModel | null { + getVariable( + name: string, + opt_type?: string, + ): IVariableModel | null { const type = opt_type || ''; - const list = this.variableMap.get(type); - if (list) { - for (let j = 0, variable; (variable = list[j]); j++) { - if (Names.equals(variable.name, name)) { - return variable; - } - } - } - return null; + const variables = this.variableMap.get(type); + if (!variables) return null; + + return ( + [...variables.values()].find((variable) => + Names.equals(variable.getName(), name), + ) ?? null + ); } /** @@ -334,12 +370,10 @@ export class VariableMap { * @param id The ID to check for. * @returns The variable with the given ID. */ - getVariableById(id: string): VariableModel | null { + getVariableById(id: string): IVariableModel | null { for (const variables of this.variableMap.values()) { - for (const variable of variables) { - if (variable.getId() === id) { - return variable; - } + if (variables.has(id)) { + return variables.get(id) ?? null; } } return null; @@ -353,36 +387,21 @@ export class VariableMap { * @returns The sought after variables of the passed in type. An empty array * if none are found. */ - getVariablesOfType(type: string | null): VariableModel[] { + getVariablesOfType(type: string | null): IVariableModel[] { type = type || ''; - const variableList = this.variableMap.get(type); - if (variableList) { - return variableList.slice(); - } - return []; + const variables = this.variableMap.get(type); + if (!variables) return []; + + return [...variables.values()]; } /** - * Return all variable and potential variable types. This list always - * contains the empty string. + * Returns a list of unique types of variables in this variable map. * - * @param ws The workspace used to look for potential variables. This can be - * different than the workspace stored on this object if the passed in ws - * is a flyout workspace. - * @returns List of variable types. - * @internal + * @returns A list of unique types of variables in this variable map. */ - getVariableTypes(ws: Workspace | null): string[] { - const variableTypes = new Set(this.variableMap.keys()); - if (ws && ws.getPotentialVariableMap()) { - for (const key of ws.getPotentialVariableMap()!.variableMap.keys()) { - variableTypes.add(key); - } - } - if (!variableTypes.has('')) { - variableTypes.add(''); - } - return Array.from(variableTypes.values()); + getTypes(): string[] { + return [...this.variableMap.keys()]; } /** @@ -390,10 +409,10 @@ export class VariableMap { * * @returns List of variable models. */ - getAllVariables(): VariableModel[] { - let allVariables: VariableModel[] = []; + getAllVariables(): IVariableModel[] { + let allVariables: IVariableModel[] = []; for (const variables of this.variableMap.values()) { - allVariables = allVariables.concat(variables); + allVariables = allVariables.concat(...variables.values()); } return allVariables; } @@ -401,34 +420,41 @@ export class VariableMap { /** * Returns all of the variable names of all types. * + * @deprecated v12: use Blockly.Variables.getAllVariables. * @returns All of the variable names of all types. */ getAllVariableNames(): string[] { - return Array.from(this.variableMap.values()) - .flat() - .map((variable) => variable.name); + deprecation.warn( + 'VariableMap.getAllVariableNames', + 'v12', + 'v13', + 'Blockly.Variables.getAllVariables', + ); + const names: string[] = []; + for (const variables of this.variableMap.values()) { + for (const variable of variables.values()) { + names.push(variable.getName()); + } + } + return names; } /** * Find all the uses of a named variable. * + * @deprecated v12: use Blockly.Variables.getVariableUsesById. * @param id ID of the variable to find. * @returns Array of block usages. */ getVariableUsesById(id: string): Block[] { - const uses = []; - const blocks = this.workspace.getAllBlocks(false); - // Iterate through every block and check the name. - for (let i = 0; i < blocks.length; i++) { - const blockVariables = blocks[i].getVarModels(); - if (blockVariables) { - for (let j = 0; j < blockVariables.length; j++) { - if (blockVariables[j].getId() === id) { - uses.push(blocks[i]); - } - } - } - } - return uses; + deprecation.warn( + 'VariableMap.getVariableUsesById', + 'v12', + 'v13', + 'Blockly.Variables.getVariableUsesById', + ); + return getVariableUsesById(this.workspace, id); } } + +registry.register(registry.Type.VARIABLE_MAP, registry.DEFAULT, VariableMap); diff --git a/core/variable_model.ts b/core/variable_model.ts index 58e48f36268..4cd16a9c321 100644 --- a/core/variable_model.ts +++ b/core/variable_model.ts @@ -14,6 +14,10 @@ // Unused import preserved for side-effects. Remove if unneeded. import './events/events_var_create.js'; +import {EventType} from './events/type.js'; +import * as eventUtils from './events/utils.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; +import * as registry from './registry.js'; import * as idGenerator from './utils/idgenerator.js'; import type {Workspace} from './workspace.js'; @@ -23,8 +27,8 @@ import type {Workspace} from './workspace.js'; * * @see {Blockly.FieldVariable} */ -export class VariableModel { - type: string; +export class VariableModel implements IVariableModel { + private type: string; private readonly id: string; /** @@ -37,8 +41,8 @@ export class VariableModel { * @param opt_id The unique ID of the variable. This will default to a UUID. */ constructor( - public workspace: Workspace, - public name: string, + private readonly workspace: Workspace, + private name: string, opt_type?: string, opt_id?: string, ) { @@ -64,16 +68,83 @@ export class VariableModel { return this.id; } + /** @returns The name of this variable. */ + getName(): string { + return this.name; + } + + /** + * Updates the user-visible name of this variable. + * + * @returns The newly-updated variable. + */ + setName(newName: string): this { + this.name = newName; + return this; + } + + /** @returns The type of this variable. */ + getType(): string { + return this.type; + } + + /** + * Updates the type of this variable. + * + * @returns The newly-updated variable. + */ + setType(newType: string): this { + this.type = newType; + return this; + } + /** - * A custom compare function for the VariableModel objects. + * Returns the workspace this VariableModel belongs to. * - * @param var1 First variable to compare. - * @param var2 Second variable to compare. - * @returns -1 if name of var1 is less than name of var2, 0 if equal, and 1 if - * greater. - * @internal + * @returns The workspace this VariableModel belongs to. */ - static compareByName(var1: VariableModel, var2: VariableModel): number { - return var1.name.localeCompare(var2.name, undefined, {sensitivity: 'base'}); + getWorkspace(): Workspace { + return this.workspace; + } + + /** + * Serializes this VariableModel. + * + * @returns a JSON representation of this VariableModel. + */ + save(): IVariableState { + const state: IVariableState = { + 'name': this.getName(), + 'id': this.getId(), + }; + const type = this.getType(); + if (type) { + state['type'] = type; + } + + return state; + } + + /** + * Loads the persisted state into a new variable in the given workspace. + * + * @param state The serialized state of a variable model from save(). + * @param workspace The workspace to create the new variable in. + */ + static load(state: IVariableState, workspace: Workspace) { + const variable = new this( + workspace, + state['name'], + state['type'], + state['id'], + ); + workspace.getVariableMap().addVariable(variable); + eventUtils.fire(new (eventUtils.get(EventType.VAR_CREATE))(variable)); } } + +registry.register( + registry.Type.VARIABLE_MODEL, + registry.DEFAULT, + VariableModel, +); diff --git a/core/variables.ts b/core/variables.ts index 491b4c1b758..f75d673903b 100644 --- a/core/variables.ts +++ b/core/variables.ts @@ -6,13 +6,16 @@ // Former goog.module ID: Blockly.Variables +import type {Block} from './block.js'; import {Blocks} from './blocks.js'; import * as dialog from './dialog.js'; import {isLegacyProcedureDefBlock} from './interfaces/i_legacy_procedure_blocks.js'; import {isVariableBackedParameterModel} from './interfaces/i_variable_backed_parameter_model.js'; +import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import {Msg} from './msg.js'; +import * as deprecation from './utils/deprecation.js'; +import type {BlockInfo, FlyoutItemInfo} from './utils/toolbox.js'; import * as utilsXml from './utils/xml.js'; -import {VariableModel} from './variable_model.js'; import type {Workspace} from './workspace.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -28,15 +31,18 @@ export const CATEGORY_NAME = 'VARIABLE'; /** * Find all user-created variables that are in use in the workspace. * For use by generators. + * * To get a list of all variables on a workspace, including unused variables, - * call Workspace.getAllVariables. + * call getAllVariables. * * @param ws The workspace to search for variables. * @returns Array of variable models. */ -export function allUsedVarModels(ws: Workspace): VariableModel[] { +export function allUsedVarModels( + ws: Workspace, +): IVariableModel[] { const blocks = ws.getAllBlocks(false); - const variables = new Set(); + const variables = new Set>(); // Iterate through every block and add each variable to the set. for (let i = 0; i < blocks.length; i++) { const blockVariables = blocks[i].getVarModels(); @@ -56,6 +62,7 @@ export function allUsedVarModels(ws: Workspace): VariableModel[] { /** * Find all developer variables used by blocks in the workspace. + * * Developer variables are never shown to the user, but are declared as global * variables in the generated code. * To declare developer variables, define the getDeveloperVariables function on @@ -81,6 +88,157 @@ export function allDeveloperVariables(workspace: Workspace): string[] { return Array.from(variables.values()); } +/** + * Internal wrapper that returns the contents of the variables category. + * + * @internal + * @param workspace The workspace to populate variable blocks for. + */ +export function internalFlyoutCategory( + workspace: WorkspaceSvg, +): FlyoutItemInfo[] { + return flyoutCategory(workspace, false); +} + +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml: true, +): Element[]; +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml: false, +): FlyoutItemInfo[]; +/** + * Construct the elements (blocks and button) required by the flyout for the + * variable category. + * + * @param workspace The workspace containing variables. + * @param useXml True to return the contents as XML, false to use JSON. + * @returns List of flyout contents as either XML or JSON. + */ +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml = true, +): Element[] | FlyoutItemInfo[] { + if (!Blocks['variables_set'] && !Blocks['variables_get']) { + console.warn( + 'There are no variable blocks, but there is a variable category.', + ); + } + + if (useXml) { + deprecation.warn( + 'The XML return value of Blockly.Variables.flyoutCategory()', + 'v12', + 'v13', + 'the same method, but handle a return type of FlyoutItemInfo[] (JSON) instead.', + ); + return xmlFlyoutCategory(workspace); + } + + workspace.registerButtonCallback('CREATE_VARIABLE', function (button) { + createVariableButtonHandler(button.getTargetWorkspace()); + }); + + return [ + { + 'kind': 'button', + 'text': '%{BKY_NEW_VARIABLE}', + 'callbackkey': 'CREATE_VARIABLE', + }, + ...jsonFlyoutCategoryBlocks( + workspace, + workspace.getVariableMap().getVariablesOfType(''), + true, + ), + ]; +} + +/** + * Returns the JSON definition for a variable field. + * + * @param variable The variable the field should reference. + * @returns JSON for a variable field. + */ +function generateVariableFieldJson(variable: IVariableModel) { + return { + 'VAR': { + 'name': variable.getName(), + 'type': variable.getType(), + }, + }; +} + +/** + * Construct the blocks required by the flyout for the variable category. + * + * @internal + * @param workspace The workspace containing variables. + * @param variables List of variables to create blocks for. + * @param includeChangeBlocks True to include `change x by _` blocks. + * @param getterType The type of the variable getter block to generate. + * @param setterType The type of the variable setter block to generate. + * @returns JSON list of blocks. + */ +export function jsonFlyoutCategoryBlocks( + workspace: Workspace, + variables: IVariableModel[], + includeChangeBlocks: boolean, + getterType = 'variables_get', + setterType = 'variables_set', +): BlockInfo[] { + includeChangeBlocks &&= Blocks['math_change']; + + const blocks = []; + const mostRecentVariable = variables.slice(-1)[0]; + if (mostRecentVariable) { + // Show one setter block, with the name of the most recently created variable. + if (Blocks[setterType]) { + blocks.push({ + kind: 'block', + type: setterType, + gap: includeChangeBlocks ? 8 : 24, + fields: generateVariableFieldJson(mostRecentVariable), + }); + } + + if (includeChangeBlocks) { + blocks.push({ + 'kind': 'block', + 'type': 'math_change', + 'gap': Blocks[getterType] ? 20 : 8, + 'fields': generateVariableFieldJson(mostRecentVariable), + 'inputs': { + 'DELTA': { + 'shadow': { + 'type': 'math_number', + 'fields': { + 'NUM': 1, + }, + }, + }, + }, + }); + } + } + + if (Blocks[getterType]) { + // Show one getter block for each variable, sorted in alphabetical order. + blocks.push( + ...variables.sort(compareByName).map((variable) => { + return { + 'kind': 'block', + 'type': getterType, + 'gap': 8, + 'fields': generateVariableFieldJson(variable), + }; + }), + ); + } + + return blocks; +} + /** * Construct the elements (blocks and button) required by the flyout for the * variable category. @@ -88,7 +246,7 @@ export function allDeveloperVariables(workspace: Workspace): string[] { * @param workspace The workspace containing variables. * @returns Array of XML elements. */ -export function flyoutCategory(workspace: WorkspaceSvg): Element[] { +function xmlFlyoutCategory(workspace: WorkspaceSvg): Element[] { let xmlList = new Array(); const button = document.createElement('button'); button.setAttribute('text', '%{BKY_NEW_VARIABLE}'); @@ -112,7 +270,7 @@ export function flyoutCategory(workspace: WorkspaceSvg): Element[] { * @returns Array of XML block elements. */ export function flyoutCategoryBlocks(workspace: Workspace): Element[] { - const variableModelList = workspace.getVariablesOfType(''); + const variableModelList = workspace.getVariableMap().getVariablesOfType(''); const xmlList = []; if (variableModelList.length > 0) { @@ -142,7 +300,7 @@ export function flyoutCategoryBlocks(workspace: Workspace): Element[] { } if (Blocks['variables_get']) { - variableModelList.sort(VariableModel.compareByName); + variableModelList.sort(compareByName); for (let i = 0, variable; (variable = variableModelList[i]); i++) { const block = utilsXml.createElement('block'); block.setAttribute('type', 'variables_get'); @@ -176,7 +334,10 @@ export function generateUniqueName(workspace: Workspace): string { function generateUniqueNameInternal(workspace: Workspace): string { return generateUniqueNameFromOptions( VAR_LETTER_OPTIONS.charAt(0), - workspace.getAllVariableNames(), + workspace + .getVariableMap() + .getAllVariables() + .map((v) => v.getName()), ); } @@ -259,17 +420,19 @@ export function createVariableButtonHandler( const existing = nameUsedWithAnyType(text, workspace); if (!existing) { // No conflict - workspace.createVariable(text, type); + workspace.getVariableMap().createVariable(text, type); if (opt_callback) opt_callback(text); return; } let msg; - if (existing.type === type) { - msg = Msg['VARIABLE_ALREADY_EXISTS'].replace('%1', existing.name); + if (existing.getType() === type) { + msg = Msg['VARIABLE_ALREADY_EXISTS'].replace('%1', existing.getName()); } else { msg = Msg['VARIABLE_ALREADY_EXISTS_FOR_ANOTHER_TYPE']; - msg = msg.replace('%1', existing.name).replace('%2', existing.type); + msg = msg + .replace('%1', existing.getName()) + .replace('%2', existing.getType()); } dialog.alert(msg, function () { promptAndCheckWithAlert(text); @@ -292,14 +455,14 @@ export function createVariableButtonHandler( */ export function renameVariable( workspace: Workspace, - variable: VariableModel, + variable: IVariableModel, opt_callback?: (p1?: string | null) => void, ) { // This function needs to be named so it can be called recursively. function promptAndCheckWithAlert(defaultName: string) { const promptText = Msg['RENAME_VARIABLE_TITLE'].replace( '%1', - variable.name, + variable.getName(), ); promptName(promptText, defaultName, function (newName) { if (!newName) { @@ -308,15 +471,19 @@ export function renameVariable( return; } - const existing = nameUsedWithOtherType(newName, variable.type, workspace); + const existing = nameUsedWithOtherType( + newName, + variable.getType(), + workspace, + ); const procedure = nameUsedWithConflictingParam( - variable.name, + variable.getName(), newName, workspace, ); if (!existing && !procedure) { // No conflict. - workspace.renameVariableById(variable.getId(), newName); + workspace.getVariableMap().renameVariable(variable, newName); if (opt_callback) opt_callback(newName); return; } @@ -324,8 +491,8 @@ export function renameVariable( let msg = ''; if (existing) { msg = Msg['VARIABLE_ALREADY_EXISTS_FOR_ANOTHER_TYPE'] - .replace('%1', existing.name) - .replace('%2', existing.type); + .replace('%1', existing.getName()) + .replace('%2', existing.getType()); } else if (procedure) { msg = Msg['VARIABLE_ALREADY_EXISTS_FOR_A_PARAMETER'] .replace('%1', newName) @@ -379,12 +546,15 @@ function nameUsedWithOtherType( name: string, type: string, workspace: Workspace, -): VariableModel | null { +): IVariableModel | null { const allVariables = workspace.getVariableMap().getAllVariables(); name = name.toLowerCase(); for (let i = 0, variable; (variable = allVariables[i]); i++) { - if (variable.name.toLowerCase() === name && variable.type !== type) { + if ( + variable.getName().toLowerCase() === name && + variable.getType() !== type + ) { return variable; } } @@ -401,12 +571,12 @@ function nameUsedWithOtherType( export function nameUsedWithAnyType( name: string, workspace: Workspace, -): VariableModel | null { +): IVariableModel | null { const allVariables = workspace.getVariableMap().getAllVariables(); name = name.toLowerCase(); for (let i = 0, variable; (variable = allVariables[i]); i++) { - if (variable.name.toLowerCase() === name) { + if (variable.getName().toLowerCase() === name) { return variable; } } @@ -452,7 +622,7 @@ function checkForConflictingParamWithProcedureModels( const params = procedure .getParameters() .filter(isVariableBackedParameterModel) - .map((param) => param.getVariableModel().name); + .map((param) => param.getVariableModel().getName()); if (!params) continue; const procHasOld = params.some((param) => param.toLowerCase() === oldName); const procHasNew = params.some((param) => param.toLowerCase() === newName); @@ -492,7 +662,7 @@ function checkForConflictingParamWithLegacyProcedures( * @returns The generated DOM. */ export function generateVariableFieldDom( - variableModel: VariableModel, + variableModel: IVariableModel, ): Element { /* Generates the following XML: * foo @@ -500,8 +670,8 @@ export function generateVariableFieldDom( const field = utilsXml.createElement('field'); field.setAttribute('name', 'VAR'); field.setAttribute('id', variableModel.getId()); - field.setAttribute('variabletype', variableModel.type); - const name = utilsXml.createTextNode(variableModel.name); + field.setAttribute('variabletype', variableModel.getType()); + const name = utilsXml.createTextNode(variableModel.getName()); field.appendChild(name); return field; } @@ -523,7 +693,7 @@ export function getOrCreateVariablePackage( id: string | null, opt_name?: string, opt_type?: string, -): VariableModel { +): IVariableModel { let variable = getVariable(workspace, id, opt_name, opt_type); if (!variable) { variable = createVariable(workspace, id, opt_name, opt_type); @@ -551,7 +721,7 @@ export function getVariable( id: string | null, opt_name?: string, opt_type?: string, -): VariableModel | null { +): IVariableModel | null { const potentialVariableMap = workspace.getPotentialVariableMap(); let variable = null; // Try to just get the variable, by ID if possible. @@ -596,7 +766,8 @@ function createVariable( id: string | null, opt_name?: string, opt_type?: string, -): VariableModel { +): IVariableModel { + const variableMap = workspace.getVariableMap(); const potentialVariableMap = workspace.getPotentialVariableMap(); // Variables without names get uniquely named for this workspace. if (!opt_name) { @@ -609,10 +780,14 @@ function createVariable( // Create a potential variable if in the flyout. let variable = null; if (potentialVariableMap) { - variable = potentialVariableMap.createVariable(opt_name, opt_type, id); + variable = potentialVariableMap.createVariable( + opt_name, + opt_type, + id ?? undefined, + ); } else { // In the main workspace, create a real variable. - variable = workspace.createVariable(opt_name, opt_type, id); + variable = variableMap.createVariable(opt_name, opt_type, id); } return variable; } @@ -632,8 +807,8 @@ function createVariable( */ export function getAddedVariables( workspace: Workspace, - originalVariables: VariableModel[], -): VariableModel[] { + originalVariables: IVariableModel[], +): IVariableModel[] { const allCurrentVariables = workspace.getAllVariables(); const addedVariables = []; if (originalVariables.length !== allCurrentVariables.length) { @@ -649,6 +824,108 @@ export function getAddedVariables( return addedVariables; } +/** + * A custom compare function for the VariableModel objects. + * + * @param var1 First variable to compare. + * @param var2 Second variable to compare. + * @returns -1 if name of var1 is less than name of var2, 0 if equal, and 1 if + * greater. + * @internal + */ +export function compareByName( + var1: IVariableModel, + var2: IVariableModel, +): number { + return var1 + .getName() + .localeCompare(var2.getName(), undefined, {sensitivity: 'base'}); +} + +/** + * Find all the uses of a named variable. + * + * @param workspace The workspace to search for the variable. + * @param id ID of the variable to find. + * @returns Array of block usages. + */ +export function getVariableUsesById(workspace: Workspace, id: string): Block[] { + const uses = []; + const blocks = workspace.getAllBlocks(false); + // Iterate through every block and check the name. + for (let i = 0; i < blocks.length; i++) { + const blockVariables = blocks[i].getVarModels(); + if (blockVariables) { + for (let j = 0; j < blockVariables.length; j++) { + if (blockVariables[j].getId() === id) { + uses.push(blocks[i]); + } + } + } + } + return uses; +} + +/** + * Delete a variable and all of its uses from the given workspace. May prompt + * the user for confirmation. + * + * @param workspace The workspace from which to delete the variable. + * @param variable The variable to delete. + * @param triggeringBlock The block from which this deletion was triggered, if + * any. Used to exclude it from checking and warning about blocks + * referencing the variable being deleted. + */ +export function deleteVariable( + workspace: Workspace, + variable: IVariableModel, + triggeringBlock?: Block, +) { + // Check whether this variable is a function parameter before deleting. + const variableName = variable.getName(); + const uses = getVariableUsesById(workspace, variable.getId()); + for (let i = uses.length - 1; i >= 0; i--) { + const block = uses[i]; + if ( + block.type === 'procedures_defnoreturn' || + block.type === 'procedures_defreturn' + ) { + const procedureName = String(block.getFieldValue('NAME')); + const deleteText = Msg['CANNOT_DELETE_VARIABLE_PROCEDURE'] + .replace('%1', variableName) + .replace('%2', procedureName); + dialog.alert(deleteText); + return; + } + if (block === triggeringBlock) { + uses.splice(i, 1); + } + } + + if ((triggeringBlock && uses.length) || uses.length > 1) { + // Confirm before deleting multiple blocks. + const confirmText = Msg['DELETE_VARIABLE_CONFIRMATION'] + .replace( + '%1', + String( + uses.length + + (triggeringBlock && !triggeringBlock.workspace.isFlyout ? 1 : 0), + ), + ) + .replace('%2', variableName); + dialog.confirm(confirmText, (ok) => { + if (ok && variable) { + workspace.getVariableMap().deleteVariable(variable); + } + }); + } else { + // No confirmation necessary when the block that triggered the deletion is + // the only block referencing this variable or if only one block referencing + // this variable exists and the deletion was triggered programmatically. + workspace.getVariableMap().deleteVariable(variable); + } +} + export const TEST_ONLY = { generateUniqueNameInternal, }; diff --git a/core/variables_dynamic.ts b/core/variables_dynamic.ts index 9788962c7b2..f8169d28123 100644 --- a/core/variables_dynamic.ts +++ b/core/variables_dynamic.ts @@ -9,8 +9,9 @@ import {Blocks} from './blocks.js'; import type {FlyoutButton} from './flyout_button.js'; import {Msg} from './msg.js'; +import * as deprecation from './utils/deprecation.js'; +import type {FlyoutItemInfo} from './utils/toolbox.js'; import * as xml from './utils/xml.js'; -import {VariableModel} from './variable_model.js'; import * as Variables from './variables.js'; import type {Workspace} from './workspace.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -69,6 +70,92 @@ function colourButtonClickHandler(button: FlyoutButton) { // eslint-disable-next-line camelcase export const onCreateVariableButtonClick_Colour = colourButtonClickHandler; +/** + * Internal wrapper that returns the contents of the dynamic variables category. + * + * @internal + * @param workspace The workspace to populate variable blocks for. + */ +export function internalFlyoutCategory( + workspace: WorkspaceSvg, +): FlyoutItemInfo[] { + return flyoutCategory(workspace, false); +} + +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml: true, +): Element[]; +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml: false, +): FlyoutItemInfo[]; +/** + * Construct the elements (blocks and button) required by the flyout for the + * dynamic variables category. + * + * @param useXml True to return the contents as XML, false to use JSON. + * @returns List of flyout contents as either XML or JSON. + */ +export function flyoutCategory( + workspace: WorkspaceSvg, + useXml = true, +): Element[] | FlyoutItemInfo[] { + if (!Blocks['variables_set_dynamic'] && !Blocks['variables_get_dynamic']) { + console.warn( + 'There are no dynamic variable blocks, but there is a dynamic variable category.', + ); + } + + if (useXml) { + deprecation.warn( + 'The XML return value of Blockly.VariablesDynamic.flyoutCategory()', + 'v12', + 'v13', + 'the same method, but handle a return type of FlyoutItemInfo[] (JSON) instead.', + ); + return xmlFlyoutCategory(workspace); + } + + workspace.registerButtonCallback( + 'CREATE_VARIABLE_STRING', + stringButtonClickHandler, + ); + workspace.registerButtonCallback( + 'CREATE_VARIABLE_NUMBER', + numberButtonClickHandler, + ); + workspace.registerButtonCallback( + 'CREATE_VARIABLE_COLOUR', + colourButtonClickHandler, + ); + + return [ + { + 'kind': 'button', + 'text': Msg['NEW_STRING_VARIABLE'], + 'callbackkey': 'CREATE_VARIABLE_STRING', + }, + { + 'kind': 'button', + 'text': Msg['NEW_NUMBER_VARIABLE'], + 'callbackkey': 'CREATE_VARIABLE_NUMBER', + }, + { + 'kind': 'button', + 'text': Msg['NEW_COLOUR_VARIABLE'], + 'callbackkey': 'CREATE_VARIABLE_COLOUR', + }, + ...Variables.jsonFlyoutCategoryBlocks( + workspace, + workspace.getVariableMap().getAllVariables(), + false, + 'variables_get_dynamic', + 'variables_set_dynamic', + ), + ]; +} + /** * Construct the elements (blocks and button) required by the flyout for the * variable category. @@ -76,7 +163,7 @@ export const onCreateVariableButtonClick_Colour = colourButtonClickHandler; * @param workspace The workspace containing variables. * @returns Array of XML elements. */ -export function flyoutCategory(workspace: WorkspaceSvg): Element[] { +function xmlFlyoutCategory(workspace: WorkspaceSvg): Element[] { let xmlList = new Array(); let button = document.createElement('button'); button.setAttribute('text', Msg['NEW_STRING_VARIABLE']); @@ -116,7 +203,7 @@ export function flyoutCategory(workspace: WorkspaceSvg): Element[] { * @returns Array of XML block elements. */ export function flyoutCategoryBlocks(workspace: Workspace): Element[] { - const variableModelList = workspace.getAllVariables(); + const variableModelList = workspace.getVariableMap().getAllVariables(); const xmlList = []; if (variableModelList.length > 0) { @@ -129,7 +216,7 @@ export function flyoutCategoryBlocks(workspace: Workspace): Element[] { xmlList.push(block); } if (Blocks['variables_get_dynamic']) { - variableModelList.sort(VariableModel.compareByName); + variableModelList.sort(Variables.compareByName); for (let i = 0, variable; (variable = variableModelList[i]); i++) { const block = xml.createElement('block'); block.setAttribute('type', 'variables_get_dynamic'); diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index 897698611e0..936983e8f10 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -8,6 +8,7 @@ import * as common from './common.js'; import {Field} from './field.js'; +import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; import * as dom from './utils/dom.js'; import type {Rect} from './utils/rect.js'; import type {Size} from './utils/size.js'; @@ -34,6 +35,9 @@ let themeClassName = ''; /** The HTML container for popup overlays (e.g. editor widgets). */ let containerDiv: HTMLDivElement | null; +/** Callback to FocusManager to return ephemeral focus when the div closes. */ +let returnEphemeralFocus: ReturnEphemeralFocus | null = null; + /** * Returns the HTML container for editor widgets. * @@ -80,12 +84,18 @@ export function createDom() { * @param newDispose Optional cleanup function to be run when the widget is * closed. * @param workspace The workspace associated with the widget owner. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the widget div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. */ export function show( newOwner: unknown, rtl: boolean, newDispose: () => void, workspace?: WorkspaceSvg | null, + manageEphemeralFocus: boolean = true, ) { hide(); owner = newOwner; @@ -110,6 +120,9 @@ export function show( if (themeClassName) { dom.addClass(div, themeClassName); } + if (manageEphemeralFocus) { + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + } } /** @@ -126,8 +139,10 @@ export function hide() { div.style.display = 'none'; div.style.left = ''; div.style.top = ''; - if (dispose) dispose(); - dispose = null; + if (dispose) { + dispose(); + dispose = null; + } div.textContent = ''; if (rendererClassName) { @@ -139,6 +154,11 @@ export function hide() { themeClassName = ''; } (common.getMainWorkspace() as WorkspaceSvg).markFocused(); + + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } } /** @@ -166,10 +186,22 @@ export function hideIfOwner(oldOwner: unknown) { * Destroy the widget and hide the div if it is being used by an object in the * specified workspace, or if it is used by an unknown workspace. * - * @param oldOwnerWorkspace The workspace that was using this container. + * @param workspace The workspace that was using this container. */ -export function hideIfOwnerIsInWorkspace(oldOwnerWorkspace: WorkspaceSvg) { - if (ownerWorkspace === null || ownerWorkspace === oldOwnerWorkspace) { +export function hideIfOwnerIsInWorkspace(workspace: WorkspaceSvg) { + let ownerIsInWorkspace = ownerWorkspace === null; + // Check if the given workspace is a parent workspace of the one containing + // our owner. + let currentWorkspace: WorkspaceSvg | null = workspace; + while (!ownerIsInWorkspace && currentWorkspace) { + if (currentWorkspace === workspace) { + ownerIsInWorkspace = true; + break; + } + currentWorkspace = workspace.options.parentWorkspace; + } + + if (ownerIsInWorkspace) { hide(); } } diff --git a/core/workspace.ts b/core/workspace.ts index 89c79723726..f7b866447c4 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -21,24 +21,28 @@ import * as common from './common.js'; import type {ConnectionDB} from './connection_db.js'; import type {Abstract} from './events/events_abstract.js'; import * as eventUtils from './events/utils.js'; -import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import {IProcedureMap} from './interfaces/i_procedure_map.js'; +import type {IVariableMap} from './interfaces/i_variable_map.js'; +import type { + IVariableModel, + IVariableState, +} from './interfaces/i_variable_model.js'; import {ObservableProcedureMap} from './observable_procedure_map.js'; import {Options} from './options.js'; import * as registry from './registry.js'; import * as arrayUtils from './utils/array.js'; +import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as math from './utils/math.js'; import type * as toolbox from './utils/toolbox.js'; -import {VariableMap} from './variable_map.js'; -import type {VariableModel} from './variable_model.js'; +import {deleteVariable, getVariableUsesById} from './variables.js'; /** * Class for a workspace. This is a data structure that contains blocks. * There is no UI, and can be created headlessly. */ -export class Workspace implements IASTNodeLocation { +export class Workspace { /** * Angle away from the horizontal to sweep for blocks. Order of execution is * generally top to bottom, but a small angle changes the scan to give a bit @@ -107,8 +111,9 @@ export class Workspace implements IASTNodeLocation { protected redoStack_: Abstract[] = []; private readonly blockDB = new Map(); private readonly typedBlocksDB = new Map(); - private variableMap: VariableMap; + private variableMap: IVariableMap>; private procedureMap: IProcedureMap = new ObservableProcedureMap(); + private readOnly = false; /** * Blocks in the flyout can refer to variables that don't exist in the main @@ -118,7 +123,9 @@ export class Workspace implements IASTNodeLocation { * these by tracking "potential" variables in the flyout. These variables * become real when references to them are dragged into the main workspace. */ - private potentialVariableMap: VariableMap | null = null; + private potentialVariableMap: IVariableMap< + IVariableModel + > | null = null; /** @param opt_options Dictionary of options. */ constructor(opt_options?: Options) { @@ -144,7 +151,10 @@ export class Workspace implements IASTNodeLocation { * all of the named variables in the workspace, including variables that are * not currently in use. */ + const VariableMap = this.getVariableMapClass(); this.variableMap = new VariableMap(this); + + this.setIsReadOnly(this.options.readOnly); } /** @@ -357,7 +367,14 @@ export class Workspace implements IASTNodeLocation { this.topComments[this.topComments.length - 1].dispose(); } eventUtils.setGroup(existingGroup); - this.variableMap.clear(); + // If this is a flyout workspace, its variable map is shared with the + // parent workspace, so we either don't want to disturb it if we're just + // disposing the flyout, or if the flyout is being disposed because the + // main workspace is being disposed, then the main workspace will handle + // cleaning it up. + if (!this.isFlyout) { + this.variableMap.clear(); + } if (this.potentialVariableMap) { this.potentialVariableMap.clear(); } @@ -368,19 +385,29 @@ export class Workspace implements IASTNodeLocation { /* Begin functions that are just pass-throughs to the variable map. */ /** - * Rename a variable by updating its name in the variable map. Identify the - * variable to rename with the given ID. + * Rename a variable by updating its name in the variable + * map. Identify the variable to rename with the given ID. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().renameVariable * @param id ID of the variable to rename. * @param newName New variable name. */ renameVariableById(id: string, newName: string) { - this.variableMap.renameVariableById(id, newName); + deprecation.warn( + 'Blockly.Workspace.renameVariableById', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().renameVariable', + ); + const variable = this.variableMap.getVariableById(id); + if (!variable) return; + this.variableMap.renameVariable(variable, newName); } /** * Create a variable with a given name, optional type, and optional ID. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().createVariable. * @param name The name of the variable. This must be unique across variables * and procedures. * @param opt_type The type of the variable like 'int' or 'string'. @@ -393,40 +420,79 @@ export class Workspace implements IASTNodeLocation { name: string, opt_type?: string | null, opt_id?: string | null, - ): VariableModel { - return this.variableMap.createVariable(name, opt_type, opt_id); + ): IVariableModel { + deprecation.warn( + 'Blockly.Workspace.createVariable', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().createVariable', + ); + return this.variableMap.createVariable( + name, + opt_type ?? undefined, + opt_id ?? undefined, + ); } /** * Find all the uses of the given variable, which is identified by ID. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().getVariableUsesById * @param id ID of the variable to find. * @returns Array of block usages. */ getVariableUsesById(id: string): Block[] { - return this.variableMap.getVariableUsesById(id); + deprecation.warn( + 'Blockly.Workspace.getVariableUsesById', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().getVariableUsesById', + ); + return getVariableUsesById(this, id); } /** * Delete a variables by the passed in ID and all of its uses from this * workspace. May prompt the user for confirmation. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().deleteVariable. * @param id ID of variable to delete. */ deleteVariableById(id: string) { - this.variableMap.deleteVariableById(id); + deprecation.warn( + 'Blockly.Workspace.deleteVariableById', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().deleteVariable', + ); + const variable = this.variableMap.getVariableById(id); + if (!variable) { + console.warn(`Can't delete non-existent variable: ${id}`); + return; + } + deleteVariable(this, variable); } /** * Find the variable by the given name and return it. Return null if not * found. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().getVariable. * @param name The name to check for. * @param opt_type The type of the variable. If not provided it defaults to * the empty string, which is a specific type. * @returns The variable with the given name. */ - getVariable(name: string, opt_type?: string): VariableModel | null { + getVariable( + name: string, + opt_type?: string, + ): IVariableModel | null { + deprecation.warn( + 'Blockly.Workspace.getVariable', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().getVariable', + ); // TODO (#1559): Possibly delete this function after resolving #1559. return this.variableMap.getVariable(name, opt_type); } @@ -434,10 +500,17 @@ export class Workspace implements IASTNodeLocation { /** * Find the variable by the given ID and return it. Return null if not found. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().getVariableById. * @param id The ID to check for. * @returns The variable with the given ID. */ - getVariableById(id: string): VariableModel | null { + getVariableById(id: string): IVariableModel | null { + deprecation.warn( + 'Blockly.Workspace.getVariableById', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().getVariableById', + ); return this.variableMap.getVariableById(id); } @@ -445,40 +518,51 @@ export class Workspace implements IASTNodeLocation { * Find the variable with the specified type. If type is null, return list of * variables with empty string type. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().getVariablesOfType. * @param type Type of the variables to find. * @returns The sought after variables of the passed in type. An empty array * if none are found. */ - getVariablesOfType(type: string | null): VariableModel[] { - return this.variableMap.getVariablesOfType(type); - } - - /** - * Return all variable types. - * - * @returns List of variable types. - * @internal - */ - getVariableTypes(): string[] { - return this.variableMap.getVariableTypes(this); + getVariablesOfType(type: string | null): IVariableModel[] { + deprecation.warn( + 'Blockly.Workspace.getVariablesOfType', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().getVariablesOfType', + ); + return this.variableMap.getVariablesOfType(type ?? ''); } /** * Return all variables of all types. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().getAllVariables. * @returns List of variable models. */ - getAllVariables(): VariableModel[] { + getAllVariables(): IVariableModel[] { + deprecation.warn( + 'Blockly.Workspace.getAllVariables', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().getAllVariables', + ); return this.variableMap.getAllVariables(); } /** * Returns all variable names of all types. * + * @deprecated v12: use Blockly.Workspace.getVariableMap().getAllVariables. * @returns List of all variable names of all types. */ getAllVariableNames(): string[] { - return this.variableMap.getAllVariableNames(); + deprecation.warn( + 'Blockly.Workspace.getAllVariableNames', + 'v12', + 'v13', + 'Blockly.Workspace.getVariableMap().getAllVariables', + ); + return this.variableMap.getAllVariables().map((v) => v.getName()); } /* End functions that are just pass-throughs to the variable map. */ /** @@ -770,9 +854,10 @@ export class Workspace implements IASTNodeLocation { * These exist in the flyout but not in the workspace. * * @returns The potential variable map. - * @internal */ - getPotentialVariableMap(): VariableMap | null { + getPotentialVariableMap(): IVariableMap< + IVariableModel + > | null { return this.potentialVariableMap; } @@ -782,7 +867,8 @@ export class Workspace implements IASTNodeLocation { * @internal */ createPotentialVariableMap() { - this.potentialVariableMap = new VariableMap(this); + const VariableMap = this.getVariableMapClass(); + this.potentialVariableMap = new VariableMap(this, true); } /** @@ -790,7 +876,7 @@ export class Workspace implements IASTNodeLocation { * * @returns The variable map. */ - getVariableMap(): VariableMap { + getVariableMap(): IVariableMap> { return this.variableMap; } @@ -800,7 +886,7 @@ export class Workspace implements IASTNodeLocation { * @param variableMap The variable map. * @internal */ - setVariableMap(variableMap: VariableMap) { + setVariableMap(variableMap: IVariableMap>) { this.variableMap = variableMap; } @@ -849,4 +935,37 @@ export class Workspace implements IASTNodeLocation { static getAll(): Workspace[] { return common.getAllWorkspaces(); } + + protected getVariableMapClass(): new ( + ...p1: any[] + ) => IVariableMap> { + const VariableMap = registry.getClassFromOptions( + registry.Type.VARIABLE_MAP, + this.options, + true, + ); + if (!VariableMap) { + throw new Error('No variable map is registered.'); + } + return VariableMap; + } + + /** + * Returns whether or not this workspace is in readonly mode. + * + * @returns True if the workspace is readonly, otherwise false. + */ + isReadOnly(): boolean { + return this.readOnly; + } + + /** + * Sets whether or not this workspace is in readonly mode. + * + * @param readOnly True to make the workspace readonly, otherwise false. + */ + setIsReadOnly(readOnly: boolean) { + this.readOnly = readOnly; + this.options.readOnly = readOnly; + } } diff --git a/core/workspace_dragger.ts b/core/workspace_dragger.ts index 7ad5651f791..312015e8556 100644 --- a/core/workspace_dragger.ts +++ b/core/workspace_dragger.ts @@ -11,7 +11,6 @@ */ // Former goog.module ID: Blockly.WorkspaceDragger -import * as common from './common.js'; import {Coordinate} from './utils/coordinate.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -56,11 +55,7 @@ export class WorkspaceDragger { * * @internal */ - startDrag() { - if (common.getSelected()) { - common.getSelected()!.unselect(); - } - } + startDrag() {} /** * Finish dragging the workspace and put everything back where it belongs. diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 58ef928064c..3e8731afd4b 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -33,25 +33,36 @@ import { ContextMenuRegistry, } from './contextmenu_registry.js'; import * as dropDownDiv from './dropdowndiv.js'; +import {Abstract as AbstractEvent} from './events/events.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {Flyout} from './flyout_base.js'; import type {FlyoutButton} from './flyout_button.js'; +import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; -import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js'; +import {isAutoHideable} from './interfaces/i_autohideable.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {IDragTarget} from './interfaces/i_drag_target.js'; import type {IFlyout} from './interfaces/i_flyout.js'; +import { + isFocusableNode, + type IFocusableNode, +} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; -import type {Cursor} from './keyboard_nav/cursor.js'; +import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Marker} from './keyboard_nav/marker.js'; import {LayerManager} from './layer_manager.js'; import {MarkerManager} from './marker_manager.js'; +import {Msg} from './msg.js'; +import {Navigator} from './navigator.js'; import {Options} from './options.js'; import * as Procedures from './procedures.js'; import * as registry from './registry.js'; -import * as renderManagement from './render_management.js'; import * as blockRendering from './renderers/common/block_rendering.js'; import type {Renderer} from './renderers/common/renderer.js'; import type {ScrollbarPair} from './scrollbar_pair.js'; @@ -60,6 +71,7 @@ import {Classic} from './theme/classic.js'; import {ThemeManager} from './theme_manager.js'; import * as Tooltip from './tooltip.js'; import type {Trashcan} from './trashcan.js'; +import * as aria from './utils/aria.js'; import * as arrayUtils from './utils/array.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; @@ -71,7 +83,6 @@ import {Svg} from './utils/svg.js'; import * as svgMath from './utils/svg_math.js'; import * as toolbox from './utils/toolbox.js'; import * as userAgent from './utils/useragent.js'; -import type {VariableModel} from './variable_model.js'; import * as Variables from './variables.js'; import * as VariablesDynamic from './variables_dynamic.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -86,7 +97,10 @@ const ZOOM_TO_FIT_MARGIN = 20; * Class for a workspace. This is an onscreen area with optional trashcan, * scrollbars, bubbles, and dragging. */ -export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { +export class WorkspaceSvg + extends Workspace + implements IContextMenu, IFocusableNode, IFocusableTree +{ /** * A wrapper function called when a resize event occurs. * You can pass the result to `eventHandling.unbind`. @@ -222,7 +236,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * The first parent div with 'injectionDiv' in the name, or null if not set. * Access this with getInjectionDiv. */ - private injectionDiv: Element | null = null; + private injectionDiv: HTMLElement | null = null; /** * Last known position of the page scroll. @@ -301,6 +315,9 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { /** True if keyboard accessibility mode is on, false otherwise. */ keyboardAccessibilityMode = false; + /** True iff a keyboard-initiated move ("drag") is in progress. */ + keyboardMoveInProgress = false; // TODO(#8960): Delete this. + /** The list of top-level bounded elements on the workspace. */ private topBoundedElements: IBoundedElement[] = []; @@ -319,6 +336,12 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { svgBubbleCanvas_!: SVGElement; zoomControls_: ZoomControls | null = null; + /** + * Navigator that handles moving focus between items in this workspace in + * response to keyboard navigation commands. + */ + private navigator = new Navigator(); + /** * @param options Dictionary of options. */ @@ -361,28 +384,31 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { /** Manager in charge of markers and cursors. */ this.markerManager = new MarkerManager(this); - if (Variables && Variables.flyoutCategory) { + if (Variables && Variables.internalFlyoutCategory) { this.registerToolboxCategoryCallback( Variables.CATEGORY_NAME, - Variables.flyoutCategory, + Variables.internalFlyoutCategory, ); } - if (VariablesDynamic && VariablesDynamic.flyoutCategory) { + if (VariablesDynamic && VariablesDynamic.internalFlyoutCategory) { this.registerToolboxCategoryCallback( VariablesDynamic.CATEGORY_NAME, - VariablesDynamic.flyoutCategory, + VariablesDynamic.internalFlyoutCategory, ); } - if (Procedures && Procedures.flyoutCategory) { + if (Procedures && Procedures.internalFlyoutCategory) { this.registerToolboxCategoryCallback( Procedures.CATEGORY_NAME, - Procedures.flyoutCategory, + Procedures.internalFlyoutCategory, ); this.addChangeListener(Procedures.mutatorOpenListener); } + // Set up callbacks to refresh the toolbox when variables change + this.addChangeListener(this.variableChangeCallback.bind(this)); + /** Object in charge of storing and updating the workspace theme. */ this.themeManager_ = this.options.parentWorkspace ? this.options.parentWorkspace.getThemeManager() @@ -441,28 +467,6 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { return this.componentManager; } - /** - * Add the cursor SVG to this workspaces SVG group. - * - * @param cursorSvg The SVG root of the cursor to be added to the workspace - * SVG group. - * @internal - */ - setCursorSvg(cursorSvg: SVGElement) { - this.markerManager.setCursorSvg(cursorSvg); - } - - /** - * Add the marker SVG to this workspaces SVG group. - * - * @param markerSvg The SVG root of the marker to be added to the workspace - * SVG group. - * @internal - */ - setMarkerSvg(markerSvg: SVGElement) { - this.markerManager.setMarkerSvg(markerSvg); - } - /** * Get the marker with the given ID. * @@ -483,7 +487,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * * @returns The cursor for the workspace. */ - getCursor(): Cursor | null { + getCursor(): LineCursor | null { if (this.markerManager) { return this.markerManager.getCursor(); } @@ -536,7 +540,12 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { */ refreshTheme() { if (this.svgGroup_) { - this.renderer.refreshDom(this.svgGroup_, this.getTheme()); + const isParentWorkspace = this.options.parentWorkspace === null; + this.renderer.refreshDom( + this.svgGroup_, + this.getTheme(), + isParentWorkspace ? this.getInjectionDiv() : undefined, + ); } // Update all blocks in workspace that have a style name. @@ -631,20 +640,24 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { // Before the SVG canvas, scale the coordinates. scale = this.scale; } + let ancestor: Element = element; do { // Loop through this block and every parent. - const xy = svgMath.getRelativeXY(element); - if (element === this.getCanvas() || element === this.getBubbleCanvas()) { + const xy = svgMath.getRelativeXY(ancestor); + if ( + ancestor === this.getCanvas() || + ancestor === this.getBubbleCanvas() + ) { // After the SVG canvas, don't scale the coordinates. scale = 1; } x += xy.x * scale; y += xy.y * scale; - element = element.parentNode as SVGElement; + ancestor = ancestor.parentNode as Element; } while ( - element && - element !== this.getParentSvg() && - element !== this.getInjectionDiv() + ancestor && + ancestor !== this.getParentSvg() && + ancestor !== this.getInjectionDiv() ); return new Coordinate(x, y); } @@ -682,7 +695,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * @returns The first parent div with 'injectionDiv' in the name. * @internal */ - getInjectionDiv(): Element { + getInjectionDiv(): HTMLElement { // NB: it would be better to pass this in at createDom, but is more likely // to break existing uses of Blockly. if (!this.injectionDiv) { @@ -690,7 +703,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { while (element) { const classes = element.getAttribute('class') || ''; if ((' ' + classes + ' ').includes(' injectionDiv ')) { - this.injectionDiv = element; + this.injectionDiv = element as HTMLElement; break; } element = element.parentNode as Element; @@ -734,7 +747,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * 'blocklyMutatorBackground'. * @returns The workspace's SVG group. */ - createDom(opt_backgroundClass?: string, injectionDiv?: Element): Element { + createDom(opt_backgroundClass?: string, injectionDiv?: HTMLElement): Element { if (!this.injectionDiv) { this.injectionDiv = injectionDiv ?? null; } @@ -747,7 +760,19 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * * */ - this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyWorkspace'}); + this.svgGroup_ = dom.createSvgElement(Svg.G, { + 'class': 'blocklyWorkspace', + // Only the top-level workspace should be tabbable. + 'tabindex': injectionDiv ? '0' : '-1', + 'id': this.id, + }); + if (injectionDiv) { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_ARIA_LABEL'], + ); + } // Note that a alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the @@ -760,8 +785,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { ); if (opt_backgroundClass === 'blocklyMainBackground' && this.grid) { - this.svgBackground_.style.fill = - 'url(#' + this.grid.getPatternId() + ')'; + this.svgBackground_.style.fill = 'var(--blocklyGridPattern)'; } else { this.themeManager_.subscribe( this.svgBackground_, @@ -816,9 +840,17 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { this.options, ); - if (CursorClass) this.markerManager.setCursor(new CursorClass()); + if (CursorClass) this.markerManager.setCursor(new CursorClass(this)); + + const isParentWorkspace = this.options.parentWorkspace === null; + this.renderer.createDom( + this.svgGroup_, + this.getTheme(), + isParentWorkspace ? this.getInjectionDiv() : undefined, + ); + + getFocusManager().registerTree(this); - this.renderer.createDom(this.svgGroup_, this.getTheme()); return this.svgGroup_; } @@ -903,6 +935,10 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { document.body.removeEventListener('wheel', this.dummyWheelListener); this.dummyWheelListener = null; } + + if (getFocusManager().isRegistered(this)) { + getFocusManager().unregisterTree(this); + } } /** @@ -1057,8 +1093,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { resize() { if (this.toolbox) { this.toolbox.position(); - } - if (this.flyout) { + } else if (this.flyout) { this.flyout.position(); } @@ -1098,6 +1133,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { /** * @returns The layer manager for this workspace. + * @internal */ getLayerManager(): LayerManager | null { return this.layerManager; @@ -1275,10 +1311,6 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { .flatMap((block) => block.getDescendants(false)) .filter((block) => block.isInsertionMarker()) .forEach((block) => block.queueRender()); - - renderManagement - .finishQueuedRenders() - .then(() => void this.markerManager.updateMarkers()); } /** @@ -1314,59 +1346,32 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { } /** - * Refresh the toolbox unless there's a drag in progress. + * Handles any necessary updates when a variable changes. * * @internal */ - refreshToolboxSelection() { - const ws = this.isFlyout ? this.targetWorkspace : this; - if (ws && !ws.currentGesture_ && ws.toolbox && ws.toolbox.getFlyout()) { - ws.toolbox.refreshSelection(); + private variableChangeCallback(event: AbstractEvent) { + switch (event.type) { + case EventType.VAR_CREATE: + case EventType.VAR_DELETE: + case EventType.VAR_RENAME: + case EventType.VAR_TYPE_CHANGE: + this.refreshToolboxSelection(); + break; + default: } } /** - * Rename a variable by updating its name in the variable map. Update the - * flyout to show the renamed variable immediately. - * - * @param id ID of the variable to rename. - * @param newName New variable name. - */ - override renameVariableById(id: string, newName: string) { - super.renameVariableById(id, newName); - this.refreshToolboxSelection(); - } - - /** - * Delete a variable by the passed in ID. Update the flyout to show - * immediately that the variable is deleted. - * - * @param id ID of variable to delete. - */ - override deleteVariableById(id: string) { - super.deleteVariableById(id); - this.refreshToolboxSelection(); - } - - /** - * Create a new variable with the given name. Update the flyout to show the - * new variable immediately. + * Refresh the toolbox unless there's a drag in progress. * - * @param name The new variable's name. - * @param opt_type The type of the variable like 'int' or 'string'. - * Does not need to be unique. Field_variable can filter variables based - * on their type. This will default to '' which is a specific type. - * @param opt_id The unique ID of the variable. This will default to a UUID. - * @returns The newly created variable. + * @internal */ - override createVariable( - name: string, - opt_type?: string | null, - opt_id?: string | null, - ): VariableModel { - const newVar = super.createVariable(name, opt_type, opt_id); - this.refreshToolboxSelection(); - return newVar; + refreshToolboxSelection() { + const ws = this.isFlyout ? this.targetWorkspace : this; + if (ws && !ws.currentGesture_ && ws.toolbox && ws.toolbox.getFlyout()) { + ws.toolbox.refreshSelection(); + } } /** Make a list of all the delete areas for this workspace. */ @@ -1469,12 +1474,47 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { } /** - * Is the user currently dragging a block or scrolling the flyout/workspace? + * Indicate whether a keyboard move is in progress or not. + * + * Should be called with true when a keyboard move of an IDraggable + * is starts, and false when it finishes or is aborted. * - * @returns True if currently dragging or scrolling. + * N.B.: This method is experimental and internal-only. It is + * intended only to called only from the keyboard navigation plugin. + * Its signature and behaviour may be modified, or the method + * removed, at an time without notice and without being treated + * as a breaking change. + * + * TODO(#8960): Delete this. + * + * @internal + * @param inProgress Is a keyboard-initated move in progress? + */ + setKeyboardMoveInProgress(inProgress: boolean) { + this.keyboardMoveInProgress = inProgress; + } + + /** + * Returns true iff the user is currently engaged in a drag gesture, + * or if a keyboard-initated move is in progress. + * + * Dragging gestures normally entail moving a block or other item on + * the workspace, or scrolling the flyout/workspace. + * + * Keyboard-initated movements are implemnted using the dragging + * infrastructure and are intended to emulate (a subset of) drag + * gestures and so should typically be treated as if they were a + * gesture-based drag. + * + * @returns True iff a drag gesture or keyboard move is in porgress. */ isDragging(): boolean { - return this.currentGesture_ !== null && this.currentGesture_.isDragging(); + return ( + // TODO(#8960): Query Mover.isMoving to see if move is in + // progress rather than relying on a status flag. + this.keyboardMoveInProgress || + (this.currentGesture_ !== null && this.currentGesture_.isDragging()) + ); } /** @@ -1697,13 +1737,13 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * @param e Mouse event. * @internal */ - showContextMenu(e: PointerEvent) { - if (this.options.readOnly || this.isFlyout) { + showContextMenu(e: Event) { + if (this.isReadOnly() || this.isFlyout) { return; } const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( - ContextMenuRegistry.ScopeType.WORKSPACE, - {workspace: this}, + {workspace: this, focusedNode: this}, + e, ); // Allow the developer to add or modify menuOptions. @@ -1711,7 +1751,15 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { this.configureContextMenu(menuOptions, e); } - ContextMenu.show(e, menuOptions, this.RTL, this); + let location; + if (e instanceof PointerEvent) { + location = new Coordinate(e.clientX, e.clientY); + } else { + // TODO: Get the location based on the workspace cursor location + location = svgMath.wsToScreenCoordinates(this, new Coordinate(5, 5)); + } + + ContextMenu.show(e, menuOptions, this.RTL, this, location); } /** @@ -2045,18 +2093,69 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { } /** - * Get the workspace's zoom factor. If the workspace has a parent, we call - * into the parent to get the workspace scale. + * Get the workspace's zoom factor. * * @returns The workspace zoom factor. Units: (pixels / workspaceUnit). */ getScale(): number { - if (this.options.parentWorkspace) { - return this.options.parentWorkspace.getScale(); - } return this.scale; } + /** + * Returns the absolute scale of the workspace. + * + * Workspace scaling is multiplicative; if a workspace B (e.g. a mutator editor) + * with scale Y is nested within a root workspace A with scale X, workspace B's + * effective scale is X * Y, because, as a child of A, it is already transformed + * by A's scaling factor, and then further transforms itself by its own scaling + * factor. Normally this Just Works, but for global elements (e.g. field + * editors) that are visually associated with a particular workspace but live at + * the top level of the DOM rather than being a child of their associated + * workspace, the absolute/effective scale may be needed to render + * appropriately. + * + * @returns The absolute/effective scale of the given workspace. + */ + getAbsoluteScale() { + // Returns a workspace's own scale, without regard to multiplicative scaling. + const getLocalScale = (workspace: WorkspaceSvg): number => { + // Workspaces in flyouts may have a distinct scale; use this if relevant. + if (workspace.isFlyout) { + const flyout = workspace.targetWorkspace?.getFlyout(); + if (flyout instanceof Flyout) { + return flyout.getFlyoutScale(); + } + } + + return workspace.getScale(); + }; + + const computeScale = (workspace: WorkspaceSvg, scale: number): number => { + // If the workspace has no parent, or it does have a parent but is not + // actually a child of its parent workspace in the DOM (this is the case for + // flyouts in the main workspace), we're done; just return the scale so far + // multiplied by the workspace's own scale. + if ( + !workspace.options.parentWorkspace || + !workspace.options.parentWorkspace + .getSvgGroup() + .contains(workspace.getSvgGroup()) + ) { + return scale * getLocalScale(workspace); + } + + // If there is a parent workspace, and this workspace is a child of it in + // the DOM, scales are multiplicative, so recurse up the workspace + // hierarchy. + return computeScale( + workspace.options.parentWorkspace, + scale * getLocalScale(workspace), + ); + }; + + return computeScale(this, 1); + } + /** * Scroll the workspace to a specified offset (in pixels), keeping in the * workspace bounds. See comment on workspaceSvg.scrollX for more detail on @@ -2322,7 +2421,17 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { /** * Look up the gesture that is tracking this touch stream on this workspace. - * May create a new gesture. + * + * Returns the gesture in progress, except: + * + * - If there is a keyboard-initiate move in progress then null will + * be returned - after calling event.preventDefault() and + * event.stopPropagation() to ensure the pointer event is ignored. + * - If there is a gesture in progress but event.type is + * 'pointerdown' then the in-progress gesture will be cancelled; + * this will result in null being returned. + * - If no gesutre is in progress but event is a pointerdown then a + * new gesture will be created and returned. * * @param e Pointer event. * @returns The gesture that is tracking this touch stream, or null if no @@ -2330,28 +2439,29 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * @internal */ getGesture(e: PointerEvent): Gesture | null { - const isStart = e.type === 'pointerdown'; - - const gesture = this.currentGesture_; - if (gesture) { - if (isStart && gesture.hasStarted()) { - console.warn('Tried to start the same gesture twice.'); - // That's funny. We must have missed a mouse up. - // Cancel it, rather than try to retrieve all of the state we need. - gesture.cancel(); - return null; - } - return gesture; + // TODO(#8960): Query Mover.isMoving to see if move is in progress + // rather than relying on .keyboardMoveInProgress status flag. + if (this.keyboardMoveInProgress) { + // Normally these would be called from Gesture.doStart. + e.preventDefault(); + e.stopPropagation(); + return null; } - // No gesture existed on this workspace, but this looks like the start of a - // new gesture. - if (isStart) { + const isStart = e.type === 'pointerdown'; + if (isStart && this.currentGesture_?.hasStarted()) { + console.warn('Tried to start the same gesture twice.'); + // That's funny. We must have missed a mouse up. + // Cancel it, rather than try to retrieve all of the state we need. + this.currentGesture_.cancel(); // Sets this.currentGesture_ to null. + } + if (!this.currentGesture_ && isStart) { + // No gesture existed on this workspace, but this looks like the + // start of a new gesture. this.currentGesture_ = new Gesture(e, this); - return this.currentGesture_; } - // No gesture existed and this event couldn't be the start of a new gesture. - return null; + + return this.currentGesture_; } /** @@ -2454,6 +2564,281 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { // We could call scroll here, but that has extra checks we don't need to do. this.translate(x, y); } + + /** + * Adds a CSS class to the workspace. + * + * @param className Name of class to add. + */ + addClass(className: string) { + if (this.injectionDiv) { + dom.addClass(this.injectionDiv, className); + } + } + + /** + * Removes a CSS class from the workspace. + * + * @param className Name of class to remove. + */ + removeClass(className: string) { + if (this.injectionDiv) { + dom.removeClass(this.injectionDiv, className); + } + } + + override setIsReadOnly(readOnly: boolean) { + super.setIsReadOnly(readOnly); + if (readOnly) { + this.addClass('blocklyReadOnly'); + } else { + this.removeClass('blocklyReadOnly'); + } + } + + /** + * Scrolls the provided bounds into view. + * + * In the case of small workspaces/large bounds, this function prioritizes + * getting the top left corner of the bounds into view. It also adds some + * padding around the bounds to allow the element to be comfortably in view. + * + * @internal + * @param bounds A rectangle to scroll into view, as best as possible. + * @param padding Amount of spacing to put between the bounds and the edge of + * the workspace's viewport. + */ + scrollBoundsIntoView(bounds: Rect, padding = 10) { + if (Gesture.inProgress()) { + // This can cause jumps during a drag and is only suited for keyboard nav. + return; + } + const scale = this.getScale(); + + const rawViewport = this.getMetricsManager().getViewMetrics(true); + const viewport = new Rect( + rawViewport.top, + rawViewport.top + rawViewport.height, + rawViewport.left, + rawViewport.left + rawViewport.width, + ); + + if ( + bounds.left >= viewport.left && + bounds.top >= viewport.top && + bounds.right <= viewport.right && + bounds.bottom <= viewport.bottom + ) { + // Do nothing if the block is fully inside the viewport. + return; + } + + // Add some padding to the bounds so the element is scrolled comfortably + // into view. + bounds = bounds.clone(); + bounds.top -= padding; + bounds.bottom += padding; + bounds.left -= padding; + bounds.right += padding; + + let deltaX = 0; + let deltaY = 0; + + if (bounds.left < viewport.left) { + deltaX = this.RTL + ? Math.min( + viewport.left - bounds.left, + viewport.right - bounds.right, // Don't move the right side out of view + ) + : viewport.left - bounds.left; + } else if (bounds.right > viewport.right) { + deltaX = this.RTL + ? viewport.right - bounds.right + : Math.max( + viewport.right - bounds.right, + viewport.left - bounds.left, // Don't move the left side out of view + ); + } + + if (bounds.top < viewport.top) { + deltaY = viewport.top - bounds.top; + } else if (bounds.bottom > viewport.bottom) { + deltaY = Math.max( + viewport.bottom - bounds.bottom, + viewport.top - bounds.top, // Don't move the top out of view + ); + } + + deltaX *= scale; + deltaY *= scale; + this.scroll(this.scrollX + deltaX, this.scrollY + deltaY); + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.svgGroup_; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** See IFocusableNode.canBeFocused. */ + canBeFocused(): boolean { + return true; + } + + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode( + previousNode: IFocusableNode | null, + ): IFocusableNode | null { + if (!previousNode) { + return this.getTopBlocks(true)[0] ?? null; + } else return null; + } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): Array { + return []; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + lookUpFocusableNode(id: string): IFocusableNode | null { + // Check against flyout items if this workspace is part of a flyout. Note + // that blocks may match against this pass before reaching getBlockById() + // below (but only for a flyout workspace). + const flyout = this.targetWorkspace?.getFlyout(); + if (this.isFlyout && flyout) { + for (const flyoutItem of flyout.getContents()) { + const elem = flyoutItem.getElement(); + if ( + isFocusableNode(elem) && + elem.canBeFocused() && + elem.getFocusableElement().id === id + ) { + return elem; + } + } + } + + // Search for fields and connections (based on ID indicators). + const fieldIndicatorIndex = id.indexOf('_field_'); + const connectionIndicatorIndex = id.indexOf('_connection_'); + if (fieldIndicatorIndex !== -1) { + const blockId = id.substring(0, fieldIndicatorIndex); + const block = this.getBlockById(blockId); + if (block) { + for (const field of block.getFields()) { + if (field.canBeFocused() && field.getFocusableElement().id === id) { + return field; + } + } + } + return null; + } else if (connectionIndicatorIndex !== -1) { + const blockId = id.substring(0, connectionIndicatorIndex); + const block = this.getBlockById(blockId); + if (block) { + for (const connection of block.getConnections_(true)) { + if (connection.id === id) return connection; + } + } + return null; + } + + // Search for a specific block. + const block = this.getAllBlocks(false).find( + (block) => block.getFocusableElement().id === id, + ); + if (block) return block; + + // Search for a workspace comment (semi-expensive). + for (const comment of this.getTopComments()) { + if ( + comment instanceof RenderedWorkspaceComment && + comment.canBeFocused() && + comment.getFocusableElement().id === id + ) { + return comment; + } + } + + // Search for icons and bubbles (which requires an expensive getAllBlocks). + const icons = this.getAllBlocks() + .map((block) => block.getIcons()) + .flat(); + for (const icon of icons) { + if (icon.canBeFocused() && icon.getFocusableElement().id === id) { + return icon; + } + if (hasBubble(icon)) { + const bubble = icon.getBubble(); + if ( + bubble && + bubble.canBeFocused() && + bubble.getFocusableElement().id === id + ) { + return bubble; + } + } + } + + return null; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus( + _node: IFocusableNode, + _previousTree: IFocusableTree | null, + ): void {} + + /** See IFocusableTree.onTreeBlur. */ + onTreeBlur(nextTree: IFocusableTree | null): void { + // If the flyout loses focus, make sure to close it unless focus is being + // lost to a different element on the page. + if (nextTree && this.isFlyout && this.targetWorkspace) { + // Only hide the flyout if the flyout's workspace is losing focus and that + // focus isn't returning to the flyout itself or the toolbox. + const flyout = this.targetWorkspace.getFlyout(); + const toolbox = this.targetWorkspace.getToolbox(); + if (flyout && nextTree === flyout) return; + if (toolbox && nextTree === toolbox) return; + if (toolbox) toolbox.clearSelection(); + if (flyout && isAutoHideable(flyout)) flyout.autoHide(false); + } + } + + /** + * Returns an object responsible for coordinating movement of focus between + * items on this workspace in response to keyboard navigation commands. + * + * @returns This workspace's Navigator instance. + */ + getNavigator(): Navigator { + return this.navigator; + } + + /** + * Sets the Navigator instance used by this workspace. + * + * @param newNavigator A Navigator object to coordinate movement between + * elements on the workspace. + */ + setNavigator(newNavigator: Navigator) { + this.navigator = newNavigator; + } } /** diff --git a/core/xml.ts b/core/xml.ts index cecc4dce20e..cc26d8c8a2c 100644 --- a/core/xml.ts +++ b/core/xml.ts @@ -17,12 +17,15 @@ import * as eventUtils from './events/utils.js'; import type {Field} from './field.js'; import {IconType} from './icons/icon_types.js'; import {inputTypes} from './inputs/input_types.js'; +import type { + IVariableModel, + IVariableState, +} from './interfaces/i_variable_model.js'; import * as renderManagement from './render_management.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Size} from './utils/size.js'; import * as utilsXml from './utils/xml.js'; -import type {VariableModel} from './variable_model.js'; import * as Variables from './variables.js'; import type {Workspace} from './workspace.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -87,14 +90,16 @@ export function saveWorkspaceComment( * @param variableList List of all variable models. * @returns Tree of XML elements. */ -export function variablesToDom(variableList: VariableModel[]): Element { +export function variablesToDom( + variableList: IVariableModel[], +): Element { const variables = utilsXml.createElement('variables'); for (let i = 0; i < variableList.length; i++) { const variable = variableList[i]; const element = utilsXml.createElement('variable'); - element.appendChild(utilsXml.createTextNode(variable.name)); - if (variable.type) { - element.setAttribute('type', variable.type); + element.appendChild(utilsXml.createTextNode(variable.getName())); + if (variable.getType()) { + element.setAttribute('type', variable.getType()); } element.id = variable.getId(); variables.appendChild(element); @@ -163,14 +168,10 @@ function fieldToDom(field: Field): Element | null { * @param element The XML element to which the field DOM should be attached. */ function allFieldsToDom(block: Block, element: Element) { - for (let i = 0; i < block.inputList.length; i++) { - const input = block.inputList[i]; - for (let j = 0; j < input.fieldRow.length; j++) { - const field = input.fieldRow[j]; - const fieldDom = fieldToDom(field); - if (fieldDom) { - element.appendChild(fieldDom); - } + for (const field of block.getFields()) { + const fieldDom = fieldToDom(field); + if (fieldDom) { + element.appendChild(fieldDom); } } } @@ -218,12 +219,24 @@ export function blockToDom( const comment = block.getIcon(IconType.COMMENT)!; const size = comment.getBubbleSize(); const pinned = comment.bubbleIsVisible(); + const location = comment.getBubbleLocation(); const commentElement = utilsXml.createElement('comment'); commentElement.appendChild(utilsXml.createTextNode(commentText)); commentElement.setAttribute('pinned', `${pinned}`); - commentElement.setAttribute('h', String(size.height)); - commentElement.setAttribute('w', String(size.width)); + commentElement.setAttribute('h', `${size.height}`); + commentElement.setAttribute('w', `${size.width}`); + if (location) { + commentElement.setAttribute( + 'x', + `${ + block.workspace.RTL + ? block.workspace.getWidth() - (location.x + size.width) + : location.x + }`, + ); + commentElement.setAttribute('y', `${location.y}`); + } element.appendChild(commentElement); } @@ -690,7 +703,7 @@ export function domToVariables(xmlVariables: Element, workspace: Workspace) { const name = xmlChild.textContent; if (!name) return; - workspace.createVariable(name, type, id); + workspace.getVariableMap().createVariable(name, type ?? undefined, id); } } @@ -794,6 +807,8 @@ function applyCommentTagNodes(xmlChildren: Element[], block: Block) { const pinned = xmlChild.getAttribute('pinned') === 'true'; const width = parseInt(xmlChild.getAttribute('w') ?? '50', 10); const height = parseInt(xmlChild.getAttribute('h') ?? '50', 10); + let x = parseInt(xmlChild.getAttribute('x') ?? '', 10); + const y = parseInt(xmlChild.getAttribute('y') ?? '', 10); block.setCommentText(text); const comment = block.getIcon(IconType.COMMENT)!; @@ -802,8 +817,15 @@ function applyCommentTagNodes(xmlChildren: Element[], block: Block) { } // Set the pinned state of the bubble. comment.setBubbleVisible(pinned); + // Actually show the bubble after the block has been rendered. - setTimeout(() => comment.setBubbleVisible(pinned), 1); + setTimeout(() => { + if (!isNaN(x) && !isNaN(y)) { + x = block.workspace.RTL ? block.workspace.getWidth() - (x + width) : x; + comment.setBubbleLocation(new Coordinate(x, y)); + } + comment.setBubbleVisible(pinned); + }, 1); } } diff --git a/generators/php/procedures.ts b/generators/php/procedures.ts index bad6c1443aa..c881da281e2 100644 --- a/generators/php/procedures.ts +++ b/generators/php/procedures.ts @@ -25,7 +25,7 @@ export function procedures_defreturn(block: Block, generator: PhpGenerator) { const workspace = block.workspace; const usedVariables = Variables.allUsedVarModels(workspace) || []; for (const variable of usedVariables) { - const varName = variable.name; + const varName = variable.getName(); // getVars returns parameter names, not ids, for procedure blocks if (!block.getVars().includes(varName)) { globals.push(generator.getVariableName(varName)); diff --git a/generators/python/procedures.ts b/generators/python/procedures.ts index 32eae97b9e0..9c00a7d50f1 100644 --- a/generators/python/procedures.ts +++ b/generators/python/procedures.ts @@ -25,7 +25,7 @@ export function procedures_defreturn(block: Block, generator: PythonGenerator) { const workspace = block.workspace; const usedVariables = Variables.allUsedVarModels(workspace) || []; for (const variable of usedVariables) { - const varName = variable.name; + const varName = variable.getName(); // getVars returns parameter names, not ids, for procedure blocks if (!block.getVars().includes(varName)) { globals.push(generator.getVariableName(varName)); diff --git a/msg/json/en.json b/msg/json/en.json index 50800bc27e8..e7c468d288a 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2024-04-16 23:19:53.668551", + "lastupdated": "2025-04-21 10:42:10.549634", "locale": "en", "messagedocumentation" : "qqq" }, @@ -18,6 +18,7 @@ "DELETE_X_BLOCKS": "Delete %1 Blocks", "DELETE_ALL_BLOCKS": "Delete all %1 blocks?", "CLEAN_UP": "Clean up Blocks", + "CLOSE": "Close", "COLLAPSE_BLOCK": "Collapse Block", "COLLAPSE_ALL": "Collapse Blocks", "EXPAND_BLOCK": "Expand Block", @@ -396,5 +397,37 @@ "WORKSPACE_ARIA_LABEL": "Blockly Workspace", "COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.", "DIALOG_OK": "OK", - "DIALOG_CANCEL": "Cancel" + "DIALOG_CANCEL": "Cancel", + "DELETE_SHORTCUT": "Delete block (%1)", + "DELETE_KEY": "Del", + "EDIT_BLOCK_CONTENTS": "Edit Block contents (%1)", + "INSERT_BLOCK": "Insert Block (%1)", + "START_MOVE": "Start move", + "FINISH_MOVE": "Finish move", + "ABORT_MOVE": "Abort move", + "MOVE_LEFT_CONSTRAINED": "Move left, constrained", + "MOVE_RIGHT_CONSTRAINED": "Move right constrained", + "MOVE_UP_CONSTRAINED": "Move up, constrained", + "MOVE_DOWN_CONSTRAINED": "Move down constrained", + "MOVE_LEFT_UNCONSTRAINED": "Move left, unconstrained", + "MOVE_RIGHT_UNCONSTRAINED": "Move right, unconstrained", + "MOVE_UP_UNCONSTRAINED": "Move up unconstrained", + "MOVE_DOWN_UNCONSTRAINED": "Move down, unconstrained", + "MOVE_BLOCK": "Move Block (%1)", + "WINDOWS": "Windows", + "MAC_OS": "macOS", + "CHROME_OS": "ChromeOS", + "LINUX": "Linux", + "UNKNOWN": "Unknown", + "CONTROL_KEY": "Ctrl", + "COMMAND_KEY": "⌘ Command", + "OPTION_KEY": "⌥ Option", + "ALT_KEY": "Alt", + "CUT_SHORTCUT": "Cut (%1)", + "COPY_SHORTCUT": "Copy (%1)", + "PASTE_SHORTCUT": "Paste (%1)", + "HELP_PROMPT": "Press %1 for help on keyboard controls", + "SHORTCUTS_GENERAL": "General", + "SHORTCUTS_EDITING": "Editing", + "SHORTCUTS_CODE_NAVIGATION": "Code navigation" } diff --git a/msg/json/qqq.json b/msg/json/qqq.json index e0310717daf..5436da59f9d 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -25,6 +25,7 @@ "DELETE_X_BLOCKS": "context menu - Permanently delete the %1 selected blocks.\n\nParameters:\n* %1 - an integer greater than 1.", "DELETE_ALL_BLOCKS": "confirmation prompt - Question the user if they really wanted to permanently delete all %1 blocks.\n\nParameters:\n* %1 - an integer greater than 1.", "CLEAN_UP": "context menu - Reposition all the blocks so that they form a neat line.", + "CLOSE": "toast notification - Accessibility label for close button.", "COLLAPSE_BLOCK": "context menu - Make the appearance of the selected block smaller by hiding some information about it.", "COLLAPSE_ALL": "context menu - Make the appearance of all blocks smaller by hiding some information about it. Use the same terminology as in the previous message.", "EXPAND_BLOCK": "context menu - Restore the appearance of the selected block by showing information about it that was hidden (collapsed) earlier.", @@ -403,5 +404,37 @@ "WORKSPACE_ARIA_LABEL": "workspace - This text is read out when a user navigates to the workspace while using a screen reader.", "COLLAPSED_WARNINGS_WARNING": "warning - This appears if the user collapses a block, and blocks inside that block have warnings attached to them. It should inform the user that the block they collapsed contains blocks that have warnings.", "DIALOG_OK": "button label - Pressing this button closes help information.\n{{Identical|OK}}", - "DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}" + "DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}", + "DELETE_SHORTCUT": "menu label - Contextual menu item that deletes the focused block.", + "DELETE_KEY": "menu label - Keyboard shortcut for the Delete key, shown at the end of a menu item that deletes the focused block.", + "EDIT_BLOCK_CONTENTS": "menu label - Contextual menu item that moves the keyboard navigation cursor into a subitem of the focused block.", + "INSERT_BLOCK": "menu label - Contextual menu item that prompts the user to choose a block to insert into the program at the focused location.", + "START_MOVE": "keyboard shortcut label - Contextual menu item that starts a keyboard-driven move of the focused block.", + "FINISH_MOVE": "keyboard shortcut label - Contextual menu item that ends a keyboard-driven move of the focused block.", + "ABORT_MOVE": "keyboard shortcut label - Contextual menu item that ends a keyboard-drive move of the focused block by returning it to its original location.", + "MOVE_LEFT_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location to the left.", + "MOVE_RIGHT_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location to the right.", + "MOVE_UP_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location above it.", + "MOVE_DOWN_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location below it.", + "MOVE_LEFT_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely to the left.", + "MOVE_RIGHT_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely to the right.", + "MOVE_UP_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely upwards.", + "MOVE_DOWN_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely downwards.", + "MOVE_BLOCK": "menu label - Contextual menu item that starts a keyboard-driven block move.", + "WINDOWS": "Name of the Microsoft Windows operating system displayed in a list of keyboard shortcuts.", + "MAC_OS": "Name of the Apple macOS operating system displayed in a list of keyboard shortcuts,", + "CHROME_OS": "Name of the Google ChromeOS operating system displayed in a list of keyboard shortcuts.", + "LINUX": "Name of the GNU/Linux operating system displayed in a list of keyboard shortcuts.", + "UNKNOWN": "Placeholder name for an operating system that can't be identified in a list of keyboard shortcuts.", + "CONTROL_KEY": "Representation of the Control key used in keyboard shortcuts.", + "COMMAND_KEY": "Representation of the Mac Command key used in keyboard shortcuts.", + "OPTION_KEY": "Representation of the Mac Option key used in keyboard shortcuts.", + "ALT_KEY": "Representation of the Alt key used in keyboard shortcuts.", + "CUT_SHORTCUT": "menu label - Contextual menu item that cuts the focused item.", + "COPY_SHORTCUT": "menu label - Contextual menu item that copies the focused item.", + "PASTE_SHORTCUT": "menu label - Contextual menu item that pastes the previously copied item.", + "HELP_PROMPT": "Alert message shown to prompt users to review available keyboard shortcuts.", + "SHORTCUTS_GENERAL": "shortcut list section header - Label for general purpose keyboard shortcuts.", + "SHORTCUTS_EDITING": "shortcut list section header - Label for keyboard shortcuts related to editing a workspace.", + "SHORTCUTS_CODE_NAVIGATION": "shortcut list section header - Label for keyboard shortcuts related to moving around the workspace." } diff --git a/msg/messages.js b/msg/messages.js index 6b9d663a68b..d0c3e17688a 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -103,6 +103,9 @@ Blockly.Msg.DELETE_ALL_BLOCKS = 'Delete all %1 blocks?'; /// context menu - Reposition all the blocks so that they form a neat line. Blockly.Msg.CLEAN_UP = 'Clean up Blocks'; /** @type {string} */ +/// toast notification - Accessibility label for close button. +Blockly.Msg.CLOSE = 'Close'; +/** @type {string} */ /// context menu - Make the appearance of the selected block smaller by hiding some information about it. Blockly.Msg.COLLAPSE_BLOCK = 'Collapse Block'; /** @type {string} */ @@ -1614,3 +1617,121 @@ Blockly.Msg.DIALOG_OK = 'OK'; /** @type {string} */ /// button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}} Blockly.Msg.DIALOG_CANCEL = 'Cancel'; + +/** @type {string} */ +/// menu label - Contextual menu item that deletes the focused block. +Blockly.Msg.DELETE_SHORTCUT = 'Delete block (%1)'; +/** @type {string} */ +/// menu label - Keyboard shortcut for the Delete key, shown at the end of a +/// menu item that deletes the focused block. +Blockly.Msg.DELETE_KEY = 'Del'; +/** @type {string} */ +/// menu label - Contextual menu item that moves the keyboard navigation cursor +/// into a subitem of the focused block. +Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents (%1)'; +/** @type {string} */ +/// menu label - Contextual menu item that prompts the user to choose a block to +/// insert into the program at the focused location. +Blockly.Msg.INSERT_BLOCK = 'Insert Block (%1)'; +/** @type {string} */ +/// keyboard shortcut label - Contextual menu item that starts a keyboard-driven +/// move of the focused block. +Blockly.Msg.START_MOVE = 'Start move'; +/** @type {string} */ +/// keyboard shortcut label - Contextual menu item that ends a keyboard-driven +/// move of the focused block. +Blockly.Msg.FINISH_MOVE = 'Finish move'; +/** @type {string} */ +/// keyboard shortcut label - Contextual menu item that ends a keyboard-drive +/// move of the focused block by returning it to its original location. +Blockly.Msg.ABORT_MOVE = 'Abort move'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block to the +/// next valid location to the left. +Blockly.Msg.MOVE_LEFT_CONSTRAINED = 'Move left, constrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block to the +/// next valid location to the right. +Blockly.Msg.MOVE_RIGHT_CONSTRAINED = 'Move right constrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block to the +/// next valid location above it. +Blockly.Msg.MOVE_UP_CONSTRAINED = 'Move up, constrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block to the +/// next valid location below it. +Blockly.Msg.MOVE_DOWN_CONSTRAINED = 'Move down constrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block freely +/// to the left. +Blockly.Msg.MOVE_LEFT_UNCONSTRAINED = 'Move left, unconstrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block freely +/// to the right. +Blockly.Msg.MOVE_RIGHT_UNCONSTRAINED = 'Move right, unconstrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block freely +/// upwards. +Blockly.Msg.MOVE_UP_UNCONSTRAINED = 'Move up unconstrained'; +/** @type {string} */ +/// keyboard shortcut label - Description of shortcut that moves a block freely +/// downwards. +Blockly.Msg.MOVE_DOWN_UNCONSTRAINED = 'Move down, unconstrained'; +/** @type {string} */ +/// menu label - Contextual menu item that starts a keyboard-driven block move. +Blockly.Msg.MOVE_BLOCK = 'Move Block (%1)'; +/** @type {string} */ +/// Name of the Microsoft Windows operating system displayed in a list of +/// keyboard shortcuts. +Blockly.Msg.WINDOWS = 'Windows'; +/** @type {string} */ +/// Name of the Apple macOS operating system displayed in a list of keyboard +/// shortcuts, +Blockly.Msg.MAC_OS = 'macOS'; +/** @type {string} */ +/// Name of the Google ChromeOS operating system displayed in a list of keyboard +/// shortcuts. +Blockly.Msg.CHROME_OS = 'ChromeOS'; +/** @type {string} */ +/// Name of the GNU/Linux operating system displayed in a list of keyboard +/// shortcuts. +Blockly.Msg.LINUX = 'Linux'; +/** @type {string} */ +/// Placeholder name for an operating system that can't be identified in a list +/// of keyboard shortcuts. +Blockly.Msg.UNKNOWN = 'Unknown'; +/** @type {string} */ +/// Representation of the Control key used in keyboard shortcuts. +Blockly.Msg.CONTROL_KEY = 'Ctrl'; +/** @type {string} */ +/// Representation of the Mac Command key used in keyboard shortcuts. +Blockly.Msg.COMMAND_KEY = '⌘ Command'; +/** @type {string} */ +/// Representation of the Mac Option key used in keyboard shortcuts. +Blockly.Msg.OPTION_KEY = '⌥ Option'; +/** @type {string} */ +/// Representation of the Alt key used in keyboard shortcuts. +Blockly.Msg.ALT_KEY = 'Alt'; +/** @type {string} */ +/// menu label - Contextual menu item that cuts the focused item. +Blockly.Msg.CUT_SHORTCUT = 'Cut (%1)'; +/** @type {string} */ +/// menu label - Contextual menu item that copies the focused item. +Blockly.Msg.COPY_SHORTCUT = 'Copy (%1)'; +/** @type {string} */ +/// menu label - Contextual menu item that pastes the previously copied item. +Blockly.Msg.PASTE_SHORTCUT = 'Paste (%1)'; +/** @type {string} */ +/// Alert message shown to prompt users to review available keyboard shortcuts. +Blockly.Msg.HELP_PROMPT = 'Press %1 for help on keyboard controls'; +/** @type {string} */ +/// shortcut list section header - Label for general purpose keyboard shortcuts. +Blockly.Msg.SHORTCUTS_GENERAL = 'General'; +/** @type {string} */ +/// shortcut list section header - Label for keyboard shortcuts related to +/// editing a workspace. +Blockly.Msg.SHORTCUTS_EDITING = 'Editing' +/** @type {string} */ +/// shortcut list section header - Label for keyboard shortcuts related to +/// moving around the workspace. +Blockly.Msg.SHORTCUTS_CODE_NAVIGATION = 'Code navigation'; diff --git a/package-lock.json b/package-lock.json index 547eda9bb10..684f30e0681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly", - "version": "11.2.2", + "version": "12.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly", - "version": "11.2.2", + "version": "12.0.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 48aa56779d7..5407f21afd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "11.2.2", + "version": "12.0.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" diff --git a/tests/mocha/astnode_test.js b/tests/mocha/astnode_test.js deleted file mode 100644 index 7ffe8efb90a..00000000000 --- a/tests/mocha/astnode_test.js +++ /dev/null @@ -1,850 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {ASTNode} from '../../build/src/core/keyboard_nav/ast_node.js'; -import {assert} from '../../node_modules/chai/chai.js'; -import { - sharedTestSetup, - sharedTestTeardown, - workspaceTeardown, -} from './test_helpers/setup_teardown.js'; - -suite('ASTNode', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'input_statement', - 'message0': '%1 %2 %3 %4', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME', - }, - { - 'type': 'input_statement', - 'name': 'NAME', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'value_input', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_value', - 'name': 'NAME', - }, - ], - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'field_input', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'output': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - ]); - this.workspace = new Blockly.Workspace(); - this.cursor = this.workspace.cursor; - const statementInput1 = this.workspace.newBlock('input_statement'); - const statementInput2 = this.workspace.newBlock('input_statement'); - const statementInput3 = this.workspace.newBlock('input_statement'); - const statementInput4 = this.workspace.newBlock('input_statement'); - const fieldWithOutput = this.workspace.newBlock('field_input'); - const valueInput = this.workspace.newBlock('value_input'); - - statementInput1.nextConnection.connect(statementInput2.previousConnection); - statementInput1.inputList[0].connection.connect( - fieldWithOutput.outputConnection, - ); - statementInput2.inputList[1].connection.connect( - statementInput3.previousConnection, - ); - - this.blocks = { - statementInput1: statementInput1, - statementInput2: statementInput2, - statementInput3: statementInput3, - statementInput4: statementInput4, - fieldWithOutput: fieldWithOutput, - valueInput: valueInput, - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - - suite('HelperFunctions', function () { - test('findNextForInput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const input2 = this.blocks.statementInput1.inputList[1]; - const connection = input.connection; - const node = ASTNode.createConnectionNode(connection); - const newASTNode = node.findNextForInput(input); - assert.equal(newASTNode.getLocation(), input2.connection); - }); - - test('findPrevForInput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const input2 = this.blocks.statementInput1.inputList[1]; - const connection = input2.connection; - const node = ASTNode.createConnectionNode(connection); - const newASTNode = node.findPrevForInput(input2); - assert.equal(newASTNode.getLocation(), input.connection); - }); - - test('findNextForField', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - const field2 = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const node = ASTNode.createFieldNode(field); - const newASTNode = node.findNextForField(field); - assert.equal(newASTNode.getLocation(), field2); - }); - - test('findPrevForField', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - const field2 = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const node = ASTNode.createFieldNode(field2); - const newASTNode = node.findPrevForField(field2); - assert.equal(newASTNode.getLocation(), field); - }); - - test('navigateBetweenStacks_Forward', function () { - const node = new ASTNode( - ASTNode.types.NEXT, - this.blocks.statementInput1.nextConnection, - ); - const newASTNode = node.navigateBetweenStacks(true); - assert.equal(newASTNode.getLocation(), this.blocks.statementInput4); - }); - - test('navigateBetweenStacks_Backward', function () { - const node = new ASTNode( - ASTNode.types.BLOCK, - this.blocks.statementInput4, - ); - const newASTNode = node.navigateBetweenStacks(false); - assert.equal(newASTNode.getLocation(), this.blocks.statementInput1); - }); - test('getOutAstNodeForBlock', function () { - const node = new ASTNode( - ASTNode.types.BLOCK, - this.blocks.statementInput2, - ); - const newASTNode = node.getOutAstNodeForBlock( - this.blocks.statementInput2, - ); - assert.equal(newASTNode.getLocation(), this.blocks.statementInput1); - }); - test('getOutAstNodeForBlock_OneBlock', function () { - const node = new ASTNode( - ASTNode.types.BLOCK, - this.blocks.statementInput4, - ); - const newASTNode = node.getOutAstNodeForBlock( - this.blocks.statementInput4, - ); - assert.equal(newASTNode.getLocation(), this.blocks.statementInput4); - }); - test('findFirstFieldOrInput_', function () { - const node = new ASTNode( - ASTNode.types.BLOCK, - this.blocks.statementInput4, - ); - const field = this.blocks.statementInput4.inputList[0].fieldRow[0]; - const newASTNode = node.findFirstFieldOrInput( - this.blocks.statementInput4, - ); - assert.equal(newASTNode.getLocation(), field); - }); - }); - - suite('NavigationFunctions', function () { - setup(function () { - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'top_connection', - 'message0': '', - 'previousStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'start_block', - 'message0': '', - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'fields_and_input', - 'message0': '%1 hi %2 %3 %4', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'input_dummy', - }, - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'two_fields', - 'message0': '%1 hi', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'fields_and_input2', - 'message0': '%1 %2 %3 hi %4 bye', - 'args0': [ - { - 'type': 'input_value', - 'name': 'NAME', - }, - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME', - }, - { - 'type': 'input_statement', - 'name': 'NAME', - }, - ], - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'dummy_input', - 'message0': 'Hello', - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'dummy_inputValue', - 'message0': 'Hello %1 %2', - 'args0': [ - { - 'type': 'input_dummy', - }, - { - 'type': 'input_value', - 'name': 'NAME', - }, - ], - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'output_next', - 'message0': '', - 'output': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - 'nextStatement': null, - }, - ]); - const noNextConnection = this.workspace.newBlock('top_connection'); - const fieldAndInputs = this.workspace.newBlock('fields_and_input'); - const twoFields = this.workspace.newBlock('two_fields'); - const fieldAndInputs2 = this.workspace.newBlock('fields_and_input2'); - const noPrevConnection = this.workspace.newBlock('start_block'); - this.blocks.noNextConnection = noNextConnection; - this.blocks.fieldAndInputs = fieldAndInputs; - this.blocks.twoFields = twoFields; - this.blocks.fieldAndInputs2 = fieldAndInputs2; - this.blocks.noPrevConnection = noPrevConnection; - - const dummyInput = this.workspace.newBlock('dummy_input'); - const dummyInputValue = this.workspace.newBlock('dummy_inputValue'); - const fieldWithOutput2 = this.workspace.newBlock('field_input'); - this.blocks.dummyInput = dummyInput; - this.blocks.dummyInputValue = dummyInputValue; - this.blocks.fieldWithOutput2 = fieldWithOutput2; - - const secondBlock = this.workspace.newBlock('input_statement'); - const outputNextBlock = this.workspace.newBlock('output_next'); - this.blocks.secondBlock = secondBlock; - this.blocks.outputNextBlock = outputNextBlock; - }); - suite('Next', function () { - setup(function () { - this.singleBlockWorkspace = new Blockly.Workspace(); - const singleBlock = this.singleBlockWorkspace.newBlock('two_fields'); - this.blocks.singleBlock = singleBlock; - }); - teardown(function () { - workspaceTeardown.call(this, this.singleBlockWorkspace); - }); - - test('fromPreviousToBlock', function () { - const prevConnection = this.blocks.statementInput1.previousConnection; - const node = ASTNode.createConnectionNode(prevConnection); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), this.blocks.statementInput1); - }); - test('fromBlockToNext', function () { - const nextConnection = this.blocks.statementInput1.nextConnection; - const node = ASTNode.createBlockNode(this.blocks.statementInput1); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), nextConnection); - }); - test('fromBlockToNull', function () { - const node = ASTNode.createBlockNode(this.blocks.noNextConnection); - const nextNode = node.next(); - assert.isNull(nextNode); - }); - test('fromNextToPrevious', function () { - const nextConnection = this.blocks.statementInput1.nextConnection; - const prevConnection = this.blocks.statementInput2.previousConnection; - const node = ASTNode.createConnectionNode(nextConnection); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), prevConnection); - }); - test('fromNextToNull', function () { - const nextConnection = this.blocks.statementInput2.nextConnection; - const node = ASTNode.createConnectionNode(nextConnection); - const nextNode = node.next(); - assert.isNull(nextNode); - }); - test('fromInputToInput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const inputConnection = - this.blocks.statementInput1.inputList[1].connection; - const node = ASTNode.createInputNode(input); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), inputConnection); - }); - test('fromInputToStatementInput', function () { - const input = this.blocks.fieldAndInputs2.inputList[1]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[2].connection; - const node = ASTNode.createInputNode(input); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), inputConnection); - }); - test('fromInputToField', function () { - const input = this.blocks.fieldAndInputs2.inputList[0]; - const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const node = ASTNode.createInputNode(input); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), field); - }); - test('fromInputToNull', function () { - const input = this.blocks.fieldAndInputs2.inputList[2]; - const node = ASTNode.createInputNode(input); - const nextNode = node.next(); - assert.isNull(nextNode); - }); - test('fromOutputToBlock', function () { - const output = this.blocks.fieldWithOutput.outputConnection; - const node = ASTNode.createConnectionNode(output); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), this.blocks.fieldWithOutput); - }); - test('fromFieldToInput', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const inputConnection = - this.blocks.statementInput1.inputList[0].connection; - const node = ASTNode.createFieldNode(field); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), inputConnection); - }); - test('fromFieldToField', function () { - const field = this.blocks.fieldAndInputs.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - const field2 = this.blocks.fieldAndInputs.inputList[1].fieldRow[0]; - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), field2); - }); - test('fromFieldToNull', function () { - const field = this.blocks.twoFields.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - const nextNode = node.next(); - assert.isNull(nextNode); - }); - test('fromStackToStack', function () { - const node = ASTNode.createStackNode(this.blocks.statementInput1); - const nextNode = node.next(); - assert.equal(nextNode.getLocation(), this.blocks.statementInput4); - assert.equal(nextNode.getType(), ASTNode.types.STACK); - }); - test('fromStackToNull', function () { - const node = ASTNode.createStackNode(this.blocks.singleBlock); - const nextNode = node.next(); - assert.isNull(nextNode); - }); - }); - - suite('Previous', function () { - test('fromPreviousToNull', function () { - const prevConnection = this.blocks.statementInput1.previousConnection; - const node = ASTNode.createConnectionNode(prevConnection); - const prevNode = node.prev(); - assert.isNull(prevNode); - }); - test('fromPreviousToNext', function () { - const prevConnection = this.blocks.statementInput2.previousConnection; - const node = ASTNode.createConnectionNode(prevConnection); - const prevNode = node.prev(); - const nextConnection = this.blocks.statementInput1.nextConnection; - assert.equal(prevNode.getLocation(), nextConnection); - }); - test('fromPreviousToInput', function () { - const prevConnection = this.blocks.statementInput3.previousConnection; - const node = ASTNode.createConnectionNode(prevConnection); - const prevNode = node.prev(); - assert.isNull(prevNode); - }); - test('fromBlockToPrevious', function () { - const node = ASTNode.createBlockNode(this.blocks.statementInput1); - const prevNode = node.prev(); - const prevConnection = this.blocks.statementInput1.previousConnection; - assert.equal(prevNode.getLocation(), prevConnection); - }); - test('fromBlockToNull', function () { - const node = ASTNode.createBlockNode(this.blocks.noPrevConnection); - const prevNode = node.prev(); - assert.isNull(prevNode); - }); - test('fromBlockToOutput', function () { - const node = ASTNode.createBlockNode(this.blocks.fieldWithOutput); - const prevNode = node.prev(); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(prevNode.getLocation(), outputConnection); - }); - test('fromNextToBlock', function () { - const nextConnection = this.blocks.statementInput1.nextConnection; - const node = ASTNode.createConnectionNode(nextConnection); - const prevNode = node.prev(); - assert.equal(prevNode.getLocation(), this.blocks.statementInput1); - }); - test('fromInputToField', function () { - const input = this.blocks.statementInput1.inputList[0]; - const node = ASTNode.createInputNode(input); - const prevNode = node.prev(); - assert.equal(prevNode.getLocation(), input.fieldRow[1]); - }); - test('fromInputToNull', function () { - const input = this.blocks.fieldAndInputs2.inputList[0]; - const node = ASTNode.createInputNode(input); - const prevNode = node.prev(); - assert.isNull(prevNode); - }); - test('fromInputToInput', function () { - const input = this.blocks.fieldAndInputs2.inputList[2]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[1].connection; - const node = ASTNode.createInputNode(input); - const prevNode = node.prev(); - assert.equal(prevNode.getLocation(), inputConnection); - }); - test('fromOutputToNull', function () { - const output = this.blocks.fieldWithOutput.outputConnection; - const node = ASTNode.createConnectionNode(output); - const prevNode = node.prev(); - assert.isNull(prevNode); - }); - test('fromFieldToNull', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - const prevNode = node.prev(); - assert.isNull(prevNode); - }); - test('fromFieldToInput', function () { - const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[0].connection; - const node = ASTNode.createFieldNode(field); - const prevNode = node.prev(); - assert.equal(prevNode.getLocation(), inputConnection); - }); - test('fromFieldToField', function () { - const field = this.blocks.fieldAndInputs.inputList[1].fieldRow[0]; - const field2 = this.blocks.fieldAndInputs.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - const prevNode = node.prev(); - assert.equal(prevNode.getLocation(), field2); - }); - test('fromStackToStack', function () { - const node = ASTNode.createStackNode(this.blocks.statementInput4); - const prevNode = node.prev(); - assert.equal(prevNode.getLocation(), this.blocks.statementInput1); - assert.equal(prevNode.getType(), ASTNode.types.STACK); - }); - }); - - suite('In', function () { - setup(function () { - this.emptyWorkspace = new Blockly.Workspace(); - }); - teardown(function () { - workspaceTeardown.call(this, this.emptyWorkspace); - }); - - test('fromInputToOutput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const node = ASTNode.createInputNode(input); - const inNode = node.in(); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(inNode.getLocation(), outputConnection); - }); - test('fromInputToNull', function () { - const input = this.blocks.statementInput2.inputList[0]; - const node = ASTNode.createInputNode(input); - const inNode = node.in(); - assert.isNull(inNode); - }); - test('fromInputToPrevious', function () { - const input = this.blocks.statementInput2.inputList[1]; - const previousConnection = - this.blocks.statementInput3.previousConnection; - const node = ASTNode.createInputNode(input); - const inNode = node.in(); - assert.equal(inNode.getLocation(), previousConnection); - }); - test('fromBlockToInput', function () { - const input = this.blocks.valueInput.inputList[0]; - const node = ASTNode.createBlockNode(this.blocks.valueInput); - const inNode = node.in(); - assert.equal(inNode.getLocation(), input.connection); - }); - test('fromBlockToField', function () { - const node = ASTNode.createBlockNode(this.blocks.statementInput1); - const inNode = node.in(); - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - assert.equal(inNode.getLocation(), field); - }); - test('fromBlockToPrevious', function () { - const prevConnection = this.blocks.statementInput4.previousConnection; - const node = ASTNode.createStackNode(this.blocks.statementInput4); - const inNode = node.in(); - assert.equal(inNode.getLocation(), prevConnection); - assert.equal(inNode.getType(), ASTNode.types.PREVIOUS); - }); - test('fromBlockToNull_DummyInput', function () { - const node = ASTNode.createBlockNode(this.blocks.dummyInput); - const inNode = node.in(); - assert.isNull(inNode); - }); - test('fromBlockToInput_DummyInputValue', function () { - const node = ASTNode.createBlockNode(this.blocks.dummyInputValue); - const inputConnection = - this.blocks.dummyInputValue.inputList[1].connection; - const inNode = node.in(); - assert.equal(inNode.getLocation(), inputConnection); - }); - test('fromOuputToNull', function () { - const output = this.blocks.fieldWithOutput.outputConnection; - const node = ASTNode.createConnectionNode(output); - const inNode = node.in(); - assert.isNull(inNode); - }); - test('fromFieldToNull', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - const inNode = node.in(); - assert.isNull(inNode); - }); - test('fromWorkspaceToStack', function () { - const coordinate = new Blockly.utils.Coordinate(100, 100); - const node = ASTNode.createWorkspaceNode(this.workspace, coordinate); - const inNode = node.in(); - assert.equal(inNode.getLocation(), this.workspace.getTopBlocks()[0]); - assert.equal(inNode.getType(), ASTNode.types.STACK); - }); - test('fromWorkspaceToNull', function () { - const coordinate = new Blockly.utils.Coordinate(100, 100); - const node = ASTNode.createWorkspaceNode( - this.emptyWorkspace, - coordinate, - ); - const inNode = node.in(); - assert.isNull(inNode); - }); - test('fromStackToPrevious', function () { - const node = ASTNode.createStackNode(this.blocks.statementInput1); - const previous = this.blocks.statementInput1.previousConnection; - const inNode = node.in(); - assert.equal(inNode.getLocation(), previous); - assert.equal(inNode.getType(), ASTNode.types.PREVIOUS); - }); - test('fromStackToOutput', function () { - const node = ASTNode.createStackNode(this.blocks.fieldWithOutput2); - const output = this.blocks.fieldWithOutput2.outputConnection; - const inNode = node.in(); - assert.equal(inNode.getLocation(), output); - assert.equal(inNode.getType(), ASTNode.types.OUTPUT); - }); - test('fromStackToBlock', function () { - const node = ASTNode.createStackNode(this.blocks.dummyInput); - const inNode = node.in(); - assert.equal(inNode.getLocation(), this.blocks.dummyInput); - assert.equal(inNode.getType(), ASTNode.types.BLOCK); - }); - }); - - suite('Out', function () { - setup(function () { - const secondBlock = this.blocks.secondBlock; - const outputNextBlock = this.blocks.outputNextBlock; - this.blocks.noPrevConnection.nextConnection.connect( - secondBlock.previousConnection, - ); - secondBlock.inputList[0].connection.connect( - outputNextBlock.outputConnection, - ); - }); - - test('fromInputToBlock', function () { - const input = this.blocks.statementInput1.inputList[0]; - const node = ASTNode.createInputNode(input); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.BLOCK); - assert.equal(outNode.getLocation(), this.blocks.statementInput1); - }); - test('fromOutputToInput', function () { - const output = this.blocks.fieldWithOutput.outputConnection; - const node = ASTNode.createConnectionNode(output); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.INPUT); - assert.equal( - outNode.getLocation(), - this.blocks.statementInput1.inputList[0].connection, - ); - }); - test('fromOutputToStack', function () { - const output = this.blocks.fieldWithOutput2.outputConnection; - const node = ASTNode.createConnectionNode(output); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.fieldWithOutput2); - }); - test('fromFieldToBlock', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.BLOCK); - assert.equal(outNode.getLocation(), this.blocks.statementInput1); - }); - test('fromStackToWorkspace', function () { - const stub = sinon - .stub(this.blocks.statementInput4, 'getRelativeToSurfaceXY') - .returns({x: 10, y: 10}); - const node = ASTNode.createStackNode(this.blocks.statementInput4); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.WORKSPACE); - assert.equal(outNode.wsCoordinate.x, 10); - assert.equal(outNode.wsCoordinate.y, -10); - stub.restore(); - }); - test('fromPreviousToInput', function () { - const previous = this.blocks.statementInput3.previousConnection; - const inputConnection = - this.blocks.statementInput2.inputList[1].connection; - const node = ASTNode.createConnectionNode(previous); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.INPUT); - assert.equal(outNode.getLocation(), inputConnection); - }); - test('fromPreviousToStack', function () { - const previous = this.blocks.statementInput2.previousConnection; - const node = ASTNode.createConnectionNode(previous); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.statementInput1); - }); - test('fromNextToInput', function () { - const next = this.blocks.statementInput3.nextConnection; - const inputConnection = - this.blocks.statementInput2.inputList[1].connection; - const node = ASTNode.createConnectionNode(next); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.INPUT); - assert.equal(outNode.getLocation(), inputConnection); - }); - test('fromNextToStack', function () { - const next = this.blocks.statementInput2.nextConnection; - const node = ASTNode.createConnectionNode(next); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.statementInput1); - }); - test('fromNextToStack_NoPreviousConnection', function () { - const next = this.blocks.secondBlock.nextConnection; - const node = ASTNode.createConnectionNode(next); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.noPrevConnection); - }); - /** - * This is where there is a block with both an output connection and a - * next connection attached to an input. - */ - test('fromNextToInput_OutputAndPreviousConnection', function () { - const next = this.blocks.outputNextBlock.nextConnection; - const node = ASTNode.createConnectionNode(next); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.INPUT); - assert.equal( - outNode.getLocation(), - this.blocks.secondBlock.inputList[0].connection, - ); - }); - test('fromBlockToStack', function () { - const node = ASTNode.createBlockNode(this.blocks.statementInput2); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.statementInput1); - }); - test('fromBlockToInput', function () { - const input = this.blocks.statementInput2.inputList[1].connection; - const node = ASTNode.createBlockNode(this.blocks.statementInput3); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.INPUT); - assert.equal(outNode.getLocation(), input); - }); - test('fromTopBlockToStack', function () { - const node = ASTNode.createBlockNode(this.blocks.statementInput1); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.statementInput1); - }); - test('fromBlockToStack_OutputConnection', function () { - const node = ASTNode.createBlockNode(this.blocks.fieldWithOutput2); - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.STACK); - assert.equal(outNode.getLocation(), this.blocks.fieldWithOutput2); - }); - test('fromBlockToInput_OutputConnection', function () { - const node = ASTNode.createBlockNode(this.blocks.outputNextBlock); - const inputConnection = this.blocks.secondBlock.inputList[0].connection; - const outNode = node.out(); - assert.equal(outNode.getType(), ASTNode.types.INPUT); - assert.equal(outNode.getLocation(), inputConnection); - }); - }); - - suite('createFunctions', function () { - test('createFieldNode', function () { - const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; - const node = ASTNode.createFieldNode(field); - assert.equal(node.getLocation(), field); - assert.equal(node.getType(), ASTNode.types.FIELD); - assert.isFalse(node.isConnection()); - }); - test('createConnectionNode', function () { - const prevConnection = this.blocks.statementInput4.previousConnection; - const node = ASTNode.createConnectionNode(prevConnection); - assert.equal(node.getLocation(), prevConnection); - assert.equal(node.getType(), ASTNode.types.PREVIOUS); - assert.isTrue(node.isConnection()); - }); - test('createInputNode', function () { - const input = this.blocks.statementInput1.inputList[0]; - const node = ASTNode.createInputNode(input); - assert.equal(node.getLocation(), input.connection); - assert.equal(node.getType(), ASTNode.types.INPUT); - assert.isTrue(node.isConnection()); - }); - test('createWorkspaceNode', function () { - const coordinate = new Blockly.utils.Coordinate(100, 100); - const node = ASTNode.createWorkspaceNode(this.workspace, coordinate); - assert.equal(node.getLocation(), this.workspace); - assert.equal(node.getType(), ASTNode.types.WORKSPACE); - assert.equal(node.getWsCoordinate(), coordinate); - assert.isFalse(node.isConnection()); - }); - test('createStatementConnectionNode', function () { - const nextConnection = - this.blocks.statementInput1.inputList[1].connection; - const inputConnection = - this.blocks.statementInput1.inputList[1].connection; - const node = ASTNode.createConnectionNode(nextConnection); - assert.equal(node.getLocation(), inputConnection); - assert.equal(node.getType(), ASTNode.types.INPUT); - assert.isTrue(node.isConnection()); - }); - test('createTopNode-previous', function () { - const block = this.blocks.statementInput1; - const topNode = ASTNode.createTopNode(block); - assert.equal(topNode.getLocation(), block.previousConnection); - }); - test('createTopNode-block', function () { - const block = this.blocks.noPrevConnection; - const topNode = ASTNode.createTopNode(block); - assert.equal(topNode.getLocation(), block); - }); - test('createTopNode-output', function () { - const block = this.blocks.outputNextBlock; - const topNode = ASTNode.createTopNode(block); - assert.equal(topNode.getLocation(), block.outputConnection); - }); - }); - }); -}); diff --git a/tests/mocha/block_test.js b/tests/mocha/block_test.js index ce9036cc4d9..eda2d82a56d 100644 --- a/tests/mocha/block_test.js +++ b/tests/mocha/block_test.js @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as common from '../../build/src/core/common.js'; import {ConnectionType} from '../../build/src/core/connection_type.js'; import {EventType} from '../../build/src/core/events/type.js'; import * as eventUtils from '../../build/src/core/events/utils.js'; @@ -463,20 +462,6 @@ suite('Blocks', function () { teardown(function () { workspaceTeardown.call(this, this.workspace); }); - - test('Disposing selected shadow unhighlights parent', function () { - const parentBlock = this.parentBlock; - common.setSelected(this.shadowChild); - assert.isTrue( - parentBlock.pathObject.svgRoot.classList.contains('blocklySelected'), - 'Expected parent to be highlighted after selecting shadow child', - ); - this.shadowChild.dispose(); - assert.isFalse( - parentBlock.pathObject.svgRoot.classList.contains('blocklySelected'), - 'Expected parent to be unhighlighted after deleting shadow child', - ); - }); }); }); @@ -1105,6 +1090,18 @@ suite('Blocks', function () { ); this.textJoinBlock = this.printBlock.getInputTargetBlock('TEXT'); this.textBlock = this.textJoinBlock.getInputTargetBlock('ADD0'); + this.extraTopBlock = Blockly.Xml.domToBlock( + Blockly.utils.xml.textToDom(` + + + + drag me + + + `), + this.workspace, + ); + this.extraNestedBlock = this.extraTopBlock.getInputTargetBlock('TEXT'); }); function assertBlockIsOnlyChild(parent, child, inputName) { @@ -1116,6 +1113,10 @@ suite('Blocks', function () { assert.equal(nonParent.getChildren().length, 0); assert.isNull(nonParent.getInputTargetBlock('TEXT')); assert.isNull(orphan.getParent()); + assert.equal( + orphan.getSvgRoot().parentElement, + orphan.workspace.getCanvas(), + ); } function assertOriginalSetup() { assertBlockIsOnlyChild(this.printBlock, this.textJoinBlock, 'TEXT'); @@ -1187,6 +1188,27 @@ suite('Blocks', function () { ); assertNonParentAndOrphan(this.textJoinBlock, this.textBlock, 'ADD0'); }); + test('Setting parent to null with dragging block', function () { + this.extraTopBlock.setDragging(true); + this.textBlock.outputConnection.disconnect(); + assert.doesNotThrow( + this.textBlock.setParent.bind(this.textBlock, null), + ); + assertNonParentAndOrphan(this.textJoinBlock, this.textBlock, 'ADD0'); + assert.equal( + this.textBlock.getSvgRoot().nextSibling, + this.extraTopBlock.getSvgRoot(), + ); + }); + test('Setting parent to null with non-top dragging block', function () { + this.extraNestedBlock.setDragging(true); + this.textBlock.outputConnection.disconnect(); + assert.doesNotThrow( + this.textBlock.setParent.bind(this.textBlock, null), + ); + assertNonParentAndOrphan(this.textJoinBlock, this.textBlock, 'ADD0'); + assert.equal(this.textBlock.getSvgRoot().nextSibling, null); + }); test('Setting parent to null without disconnecting', function () { assert.throws(this.textBlock.setParent.bind(this.textBlock, null)); assertOriginalSetup.call(this); @@ -1421,6 +1443,10 @@ suite('Blocks', function () { return Blockly.utils.Size(0, 0); } + setBubbleLocation() {} + + getBubbleLocation() {} + bubbleIsVisible() { return true; } diff --git a/tests/mocha/comment_deserialization_test.js b/tests/mocha/comment_deserialization_test.js index 2517ed77982..54ee0b2ff30 100644 --- a/tests/mocha/comment_deserialization_test.js +++ b/tests/mocha/comment_deserialization_test.js @@ -110,7 +110,7 @@ suite('Comment Deserialization', function () { test('Toolbox', function () { // Place from toolbox. const toolbox = this.workspace.getToolbox(); - simulateClick(toolbox.HtmlDiv.querySelector('.blocklyTreeRow')); + simulateClick(toolbox.HtmlDiv.querySelector('.blocklyToolboxCategory')); simulateClick( toolbox.getFlyout().svgGroup_.querySelector('.blocklyPath'), ); diff --git a/tests/mocha/comment_test.js b/tests/mocha/comment_test.js index 79b3d7de662..0ff1c239e30 100644 --- a/tests/mocha/comment_test.js +++ b/tests/mocha/comment_test.js @@ -141,4 +141,30 @@ suite('Comments', function () { assertBubbleSize(this.comment, 100, 100); }); }); + suite('Set/Get Bubble Location', function () { + teardown(function () { + sinon.restore(); + }); + function assertBubbleLocation(comment, x, y) { + const location = comment.getBubbleLocation(); + assert.equal(location.x, x); + assert.equal(location.y, y); + } + test('Set Location While Visible', function () { + this.comment.setBubbleVisible(true); + + this.comment.setBubbleLocation(new Blockly.utils.Coordinate(100, 100)); + assertBubbleLocation(this.comment, 100, 100); + + this.comment.setBubbleVisible(false); + assertBubbleLocation(this.comment, 100, 100); + }); + test('Set Location While Invisible', function () { + this.comment.setBubbleLocation(new Blockly.utils.Coordinate(100, 100)); + assertBubbleLocation(this.comment, 100, 100); + + this.comment.setBubbleVisible(true); + assertBubbleLocation(this.comment, 100, 100); + }); + }); }); diff --git a/tests/mocha/contextmenu_items_test.js b/tests/mocha/contextmenu_items_test.js index a9e2bb3de62..d9044ec7e28 100644 --- a/tests/mocha/contextmenu_items_test.js +++ b/tests/mocha/contextmenu_items_test.js @@ -318,9 +318,7 @@ suite('Context Menu Items', function () { test('Deletes all blocks after confirming', function () { // Mocks the confirmation dialog and calls the callback with 'true' simulating ok. - const confirmStub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, true); + const confirmStub = sinon.stub(window, 'confirm').returns(true); this.workspace.newBlock('text'); this.workspace.newBlock('text'); @@ -328,13 +326,13 @@ suite('Context Menu Items', function () { this.clock.runAll(); sinon.assert.calledOnce(confirmStub); assert.equal(this.workspace.getTopBlocks(false).length, 0); + + confirmStub.restore(); }); test('Does not delete blocks if not confirmed', function () { // Mocks the confirmation dialog and calls the callback with 'false' simulating cancel. - const confirmStub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, false); + const confirmStub = sinon.stub(window, 'confirm').returns(false); this.workspace.newBlock('text'); this.workspace.newBlock('text'); @@ -342,19 +340,20 @@ suite('Context Menu Items', function () { this.clock.runAll(); sinon.assert.calledOnce(confirmStub); assert.equal(this.workspace.getTopBlocks(false).length, 2); + + confirmStub.restore(); }); test('No dialog for single block', function () { - const confirmStub = sinon.stub( - Blockly.dialog.TEST_ONLY, - 'confirmInternal', - ); + const confirmStub = sinon.stub(window, 'confirm'); this.workspace.newBlock('text'); this.deleteOption.callback(this.scope); this.clock.runAll(); sinon.assert.notCalled(confirmStub); assert.equal(this.workspace.getTopBlocks(false).length, 0); + + confirmStub.restore(); }); test('Has correct label for multiple blocks', function () { diff --git a/tests/mocha/contextmenu_test.js b/tests/mocha/contextmenu_test.js index fe6d4be997e..65896112bb1 100644 --- a/tests/mocha/contextmenu_test.js +++ b/tests/mocha/contextmenu_test.js @@ -6,7 +6,6 @@ import {callbackFactory} from '../../build/src/core/contextmenu.js'; import * as xmlUtils from '../../build/src/core/utils/xml.js'; -import * as Variables from '../../build/src/core/variables.js'; import {assert} from '../../node_modules/chai/chai.js'; import { sharedTestSetup, @@ -32,9 +31,13 @@ suite('Context Menu', function () { }); test('callback with xml state creates block', function () { - const xmlField = Variables.generateVariableFieldDom( - this.forLoopBlock.getField('VAR').getVariable(), - ); + const variable = this.forLoopBlock.getField('VAR').getVariable(); + const xmlField = document.createElement('field'); + xmlField.setAttribute('name', 'VAR'); + xmlField.setAttribute('id', variable.getId()); + xmlField.setAttribute('variabletype', variable.getType()); + xmlField.textContent = variable.getName(); + const xmlBlock = xmlUtils.createElement('block'); xmlBlock.setAttribute('type', 'variables_get'); xmlBlock.appendChild(xmlField); diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index bb5026d7ac3..aa4f5618495 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -4,131 +4,786 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {ASTNode} from '../../build/src/core/keyboard_nav/ast_node.js'; import {assert} from '../../node_modules/chai/chai.js'; +import {createRenderedBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, } from './test_helpers/setup_teardown.js'; suite('Cursor', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'input_statement', - 'message0': '%1 %2 %3 %4', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME', + suite('Movement', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'input_statement', + 'message0': '%1 %2 %3 %4', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME1', + 'text': 'default', + }, + { + 'type': 'field_input', + 'name': 'NAME2', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'NAME3', + }, + { + 'type': 'input_statement', + 'name': 'NAME4', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'field_input', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'output': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + const blockA = createRenderedBlock(this.workspace, 'input_statement'); + const blockB = createRenderedBlock(this.workspace, 'input_statement'); + const blockC = createRenderedBlock(this.workspace, 'input_statement'); + const blockD = createRenderedBlock(this.workspace, 'input_statement'); + const blockE = createRenderedBlock(this.workspace, 'field_input'); + + blockA.nextConnection.connect(blockB.previousConnection); + blockA.inputList[0].connection.connect(blockE.outputConnection); + blockB.inputList[1].connection.connect(blockC.previousConnection); + this.cursor.drawer = null; + this.blocks = { + A: blockA, + B: blockB, + C: blockC, + D: blockD, + E: blockE, + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Next - From a Previous connection go to the next block', function () { + const prevNode = this.blocks.A.previousConnection; + this.cursor.setCurNode(prevNode); + this.cursor.next(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.blocks.A); + }); + test('Next - From a block go to its statement input', function () { + const prevNode = this.blocks.B; + this.cursor.setCurNode(prevNode); + this.cursor.next(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.blocks.C); + }); + + test('In - From field to attached input connection', function () { + const fieldBlock = this.blocks.E; + const fieldNode = this.blocks.A.getField('NAME2'); + this.cursor.setCurNode(fieldNode); + this.cursor.in(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, fieldBlock); + }); + + test('Prev - From previous connection does skip over next connection', function () { + const prevConnection = this.blocks.B.previousConnection; + const prevConnectionNode = prevConnection; + this.cursor.setCurNode(prevConnectionNode); + this.cursor.prev(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.blocks.A); + }); + + test('Prev - From first block loop to last block', function () { + const prevConnection = this.blocks.A; + const prevConnectionNode = prevConnection; + this.cursor.setCurNode(prevConnectionNode); + this.cursor.prev(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.blocks.D); + }); + + test('Out - From field does not skip over block node', function () { + const field = this.blocks.E.inputList[0].fieldRow[0]; + const fieldNode = field; + this.cursor.setCurNode(fieldNode); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.blocks.E); + }); + + test('Out - From first connection loop to last next connection', function () { + const prevConnection = this.blocks.A.previousConnection; + const prevConnectionNode = prevConnection; + this.cursor.setCurNode(prevConnectionNode); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.blocks.D.nextConnection); + }); + }); + suite('Searching', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'empty_block', + 'message0': '', + }, + { + 'type': 'stack_block', + 'message0': '', + 'previousStatement': null, + 'nextStatement': null, + }, + { + 'type': 'row_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'INPUT', + }, + ], + 'output': null, + }, + { + 'type': 'statement_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'STATEMENT', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + { + 'type': 'c_hat_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'STATEMENT', + }, + ], + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + suite('one empty block', function () { + setup(function () { + this.blockA = this.workspace.newBlock('empty_block'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + assert.equal(node, this.blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node, this.blockA); + }); + }); + + suite('one stack block', function () { + setup(function () { + this.blockA = this.workspace.newBlock('stack_block'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + assert.equal(node, this.blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node, this.blockA); + }); + }); + + suite('one row block', function () { + setup(function () { + this.blockA = this.workspace.newBlock('row_block'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + assert.equal(node, this.blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node, this.blockA); + }); + }); + suite('one c-hat block', function () { + setup(function () { + this.blockA = this.workspace.newBlock('c_hat_block'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + assert.equal(node, this.blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node, this.blockA); + }); + }); + + suite('multiblock stack', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'stack_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'B', + }, + }, + }, + ], }, - { - 'type': 'input_statement', - 'name': 'NAME', + }; + Blockly.serialization.workspaces.load(state, this.workspace); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + const blockA = this.workspace.getBlockById('A'); + assert.equal(node, blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + const blockB = this.workspace.getBlockById('B'); + assert.equal(node, blockB); + }); + }); + + suite('multiblock row', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'row_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'inputs': { + 'INPUT': { + 'block': { + 'type': 'row_block', + 'id': 'B', + }, + }, + }, + }, + ], }, - ], - 'previousStatement': null, - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'field_input', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', + }; + Blockly.serialization.workspaces.load(state, this.workspace); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + const blockA = this.workspace.getBlockById('A'); + assert.equal(node, blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + const blockB = this.workspace.getBlockById('B'); + assert.equal(node, blockB); + }); + }); + + suite('two stacks', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'stack_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'B', + }, + }, + }, + { + 'type': 'stack_block', + 'id': 'C', + 'x': 100, + 'y': 100, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'D', + }, + }, + }, + ], }, - ], - 'output': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - const blockA = this.workspace.newBlock('input_statement'); - const blockB = this.workspace.newBlock('input_statement'); - const blockC = this.workspace.newBlock('input_statement'); - const blockD = this.workspace.newBlock('input_statement'); - const blockE = this.workspace.newBlock('field_input'); - - blockA.nextConnection.connect(blockB.previousConnection); - blockA.inputList[0].connection.connect(blockE.outputConnection); - blockB.inputList[1].connection.connect(blockC.previousConnection); - this.cursor.drawer = null; - this.blocks = { - A: blockA, - B: blockB, - C: blockC, - D: blockD, - E: blockE, - }; - }); - teardown(function () { - sharedTestTeardown.call(this); + }; + Blockly.serialization.workspaces.load(state, this.workspace); + }); + teardown(function () { + this.workspace.clear(); + }); + test('getFirstNode', function () { + const node = this.cursor.getFirstNode(); + const location = node; + const blockA = this.workspace.getBlockById('A'); + assert.equal(location, blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + const location = node; + const blockD = this.workspace.getBlockById('D'); + assert.equal(location, blockD); + }); + }); }); + suite('Get next node', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'empty_block', + 'message0': '', + }, + { + 'type': 'stack_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + { + 'type': 'row_block', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'INPUT', + }, + ], + 'output': null, + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + this.neverValid = () => false; + this.alwaysValid = () => true; + this.isBlock = (node) => { + return node && node instanceof Blockly.BlockSvg; + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + suite('stack', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'stack_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'B', + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'C', + }, + }, + }, + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(state, this.workspace); + this.blockA = this.workspace.getBlockById('A'); + this.blockB = this.workspace.getBlockById('B'); + this.blockC = this.workspace.getBlockById('C'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('Never valid - start at top', function () { + const startNode = this.blockA.previousConnection; + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start in middle', function () { + const startNode = this.blockB; + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start at end', function () { + const startNode = this.blockC.nextConnection; + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); - test('Next - From a Previous skip over next connection and block', function () { - const prevNode = ASTNode.createConnectionNode( - this.blocks.A.previousConnection, - ); - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode.getLocation(), this.blocks.B.previousConnection); - }); - test('Next - From last block in a stack go to next connection', function () { - const prevNode = ASTNode.createConnectionNode( - this.blocks.B.previousConnection, - ); - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode.getLocation(), this.blocks.B.nextConnection); - }); + test('Always valid - start at top', function () { + const startNode = this.blockA.previousConnection; + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(nextNode, this.blockA); + }); + test('Always valid - start in middle', function () { + const startNode = this.blockB; + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(nextNode, this.blockB.getField('FIELD')); + }); + test('Always valid - start at end', function () { + const startNode = this.blockC.getField('FIELD'); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.isNull(nextNode); + }); - test('In - From output connection', function () { - const fieldBlock = this.blocks.E; - const outputNode = ASTNode.createConnectionNode( - fieldBlock.outputConnection, - ); - this.cursor.setCurNode(outputNode); - this.cursor.in(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode.getLocation(), fieldBlock.inputList[0].fieldRow[0]); - }); + test('Valid if block - start at top', function () { + const startNode = this.blockA; + const nextNode = this.cursor.getNextNode( + startNode, + this.isBlock, + false, + ); + assert.equal(nextNode, this.blockB); + }); + test('Valid if block - start in middle', function () { + const startNode = this.blockB; + const nextNode = this.cursor.getNextNode( + startNode, + this.isBlock, + false, + ); + assert.equal(nextNode, this.blockC); + }); + test('Valid if block - start at end', function () { + const startNode = this.blockC.getField('FIELD'); + const nextNode = this.cursor.getNextNode( + startNode, + this.isBlock, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start at end - with loopback', function () { + const startNode = this.blockC.nextConnection; + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + true, + ); + assert.isNull(nextNode); + }); + test('Always valid - start at end - with loopback', function () { + const startNode = this.blockC.nextConnection; + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + true, + ); + assert.equal(nextNode, this.blockA.previousConnection); + }); - test('Prev - From previous connection skip over next connection', function () { - const prevConnection = this.blocks.B.previousConnection; - const prevConnectionNode = ASTNode.createConnectionNode(prevConnection); - this.cursor.setCurNode(prevConnectionNode); - this.cursor.prev(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode.getLocation(), this.blocks.A.previousConnection); + test('Valid if block - start at end - with loopback', function () { + const startNode = this.blockC; + const nextNode = this.cursor.getNextNode(startNode, this.isBlock, true); + assert.equal(nextNode, this.blockA); + }); + }); }); - test('Out - From field skip over block node', function () { - const field = this.blocks.E.inputList[0].fieldRow[0]; - const fieldNode = ASTNode.createFieldNode(field); - this.cursor.setCurNode(fieldNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode.getLocation(), this.blocks.E.outputConnection); + suite('Get previous node', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'empty_block', + 'message0': '', + }, + { + 'type': 'stack_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + { + 'type': 'row_block', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'INPUT', + }, + ], + 'output': null, + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + this.neverValid = () => false; + this.alwaysValid = () => true; + this.isBlock = (node) => { + return node && node instanceof Blockly.BlockSvg; + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + suite('stack', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'stack_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'B', + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'C', + }, + }, + }, + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(state, this.workspace); + this.blockA = this.workspace.getBlockById('A'); + this.blockB = this.workspace.getBlockById('B'); + this.blockC = this.workspace.getBlockById('C'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('Never valid - start at top', function () { + const startNode = this.blockA.previousConnection; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + test('Never valid - start in middle', function () { + const startNode = this.blockB; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + test('Never valid - start at end', function () { + const startNode = this.blockC.nextConnection; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + + test('Always valid - start at top', function () { + const startNode = this.blockA; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.isNull(previousNode); + }); + test('Always valid - start in middle', function () { + const startNode = this.blockB; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(previousNode, this.blockA.getField('FIELD')); + }); + test('Always valid - start at end', function () { + const startNode = this.blockC.nextConnection; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(previousNode, this.blockC.getField('FIELD')); + }); + + test('Valid if block - start at top', function () { + const startNode = this.blockA; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isBlock, + false, + ); + assert.isNull(previousNode); + }); + test('Valid if block - start in middle', function () { + const startNode = this.blockB; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isBlock, + false, + ); + assert.equal(previousNode, this.blockA); + }); + test('Valid if block - start at end', function () { + const startNode = this.blockC; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isBlock, + false, + ); + assert.equal(previousNode, this.blockB); + }); + test('Never valid - start at top - with loopback', function () { + const startNode = this.blockA.previousConnection; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + true, + ); + assert.isNull(previousNode); + }); + test('Always valid - start at top - with loopback', function () { + const startNode = this.blockA.previousConnection; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + true, + ); + assert.equal(previousNode, this.blockC.nextConnection); + }); + test('Valid if block - start at top - with loopback', function () { + const startNode = this.blockA; + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isBlock, + true, + ); + assert.equal(previousNode, this.blockC); + }); + }); }); }); diff --git a/tests/mocha/dialog_test.js b/tests/mocha/dialog_test.js new file mode 100644 index 00000000000..f250ff0f8aa --- /dev/null +++ b/tests/mocha/dialog_test.js @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Dialog utilities', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', {}); + }); + + teardown(function () { + sharedTestTeardown.call(this); + Blockly.dialog.setAlert(); + Blockly.dialog.setPrompt(); + Blockly.dialog.setConfirm(); + Blockly.dialog.setToast(); + }); + + test('use the browser alert by default', function () { + const alert = sinon.stub(window, 'alert'); + Blockly.dialog.alert('test'); + assert.isTrue(alert.calledWith('test')); + alert.restore(); + }); + + test('support setting a custom alert handler', function () { + const alert = sinon.spy(); + Blockly.dialog.setAlert(alert); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.alert(message, callback); + assert.isTrue(alert.calledWith('test', callback)); + }); + + test('do not call the browser alert if a custom alert handler is set', function () { + const browserAlert = sinon.stub(window, 'alert'); + + const alert = sinon.spy(); + Blockly.dialog.setAlert(alert); + Blockly.dialog.alert(test); + assert.isFalse(browserAlert.called); + + browserAlert.restore(); + }); + + test('use the browser confirm by default', function () { + const confirm = sinon.stub(window, 'confirm'); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isTrue(confirm.calledWith(message)); + confirm.restore(); + }); + + test('support setting a custom confirm handler', function () { + const confirm = sinon.spy(); + Blockly.dialog.setConfirm(confirm); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isTrue(confirm.calledWith('test', callback)); + }); + + test('do not call the browser confirm if a custom confirm handler is set', function () { + const browserConfirm = sinon.stub(window, 'confirm'); + + const confirm = sinon.spy(); + Blockly.dialog.setConfirm(confirm); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isFalse(browserConfirm.called); + + browserConfirm.restore(); + }); + + test('invokes the provided callback with the confirmation response', function () { + const confirm = sinon.stub(window, 'confirm').returns(true); + const callback = sinon.spy(); + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isTrue(callback.calledWith(true)); + confirm.restore(); + }); + + test('use the browser prompt by default', function () { + const prompt = sinon.stub(window, 'prompt'); + const callback = () => {}; + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isTrue(prompt.calledWith(message, defaultValue)); + prompt.restore(); + }); + + test('support setting a custom prompt handler', function () { + const prompt = sinon.spy(); + Blockly.dialog.setPrompt(prompt); + const callback = () => {}; + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isTrue(prompt.calledWith('test', defaultValue, callback)); + }); + + test('do not call the browser prompt if a custom prompt handler is set', function () { + const browserPrompt = sinon.stub(window, 'prompt'); + + const prompt = sinon.spy(); + Blockly.dialog.setPrompt(prompt); + const callback = () => {}; + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isFalse(browserPrompt.called); + + browserPrompt.restore(); + }); + + test('invokes the provided callback with the prompt response', function () { + const prompt = sinon.stub(window, 'prompt').returns('something'); + const callback = sinon.spy(); + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isTrue(callback.calledWith('something')); + prompt.restore(); + }); + + test('use the built-in toast by default', function () { + const message = 'test toast'; + Blockly.dialog.toast(this.workspace, {message}); + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + assert.isNotNull(toast); + assert.equal(toast.textContent, message); + }); + + test('support setting a custom toast handler', function () { + const toast = sinon.spy(); + Blockly.dialog.setToast(toast); + const message = 'test toast'; + const options = {message}; + Blockly.dialog.toast(this.workspace, options); + assert.isTrue(toast.calledWith(this.workspace, options)); + }); + + test('do not use the built-in toast if a custom toast handler is set', function () { + const builtInToast = sinon.stub(Blockly.Toast, 'show'); + + const toast = sinon.spy(); + Blockly.dialog.setToast(toast); + const message = 'test toast'; + Blockly.dialog.toast(this.workspace, {message}); + assert.isFalse(builtInToast.called); + + builtInToast.restore(); + }); +}); diff --git a/tests/mocha/event_marker_move_test.js b/tests/mocha/event_marker_move_test.js deleted file mode 100644 index cd5609c33d7..00000000000 --- a/tests/mocha/event_marker_move_test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {assert} from '../../node_modules/chai/chai.js'; -import {defineRowBlock} from './test_helpers/block_definitions.js'; -import { - sharedTestSetup, - sharedTestTeardown, -} from './test_helpers/setup_teardown.js'; - -suite('Marker Move Event', function () { - setup(function () { - sharedTestSetup.call(this); - defineRowBlock(); - this.workspace = new Blockly.Workspace(); - }); - - teardown(function () { - sharedTestTeardown.call(this); - }); - - suite('Serialization', function () { - test('events round-trip through JSON', function () { - const block1 = this.workspace.newBlock('row_block', 'test_id1'); - const block2 = this.workspace.newBlock('row_block', 'test_id2'); - const node1 = new Blockly.ASTNode(Blockly.ASTNode.types.BLOCK, block1); - const node2 = new Blockly.ASTNode(Blockly.ASTNode.types.BLOCK, block2); - const origEvent = new Blockly.Events.MarkerMove( - block2, - false, - node1, - node2, - ); - - const json = origEvent.toJson(); - const newEvent = new Blockly.Events.fromJson(json, this.workspace); - - assert.deepEqual(newEvent, origEvent); - }); - }); -}); diff --git a/tests/mocha/event_test.js b/tests/mocha/event_test.js index 84ea7f0d78e..00d704ff052 100644 --- a/tests/mocha/event_test.js +++ b/tests/mocha/event_test.js @@ -6,7 +6,6 @@ import * as Blockly from '../../build/src/core/blockly.js'; import * as eventUtils from '../../build/src/core/events/utils.js'; -import {ASTNode} from '../../build/src/core/keyboard_nav/ast_node.js'; import {assert} from '../../node_modules/chai/chai.js'; import { assertEventEquals, @@ -519,85 +518,6 @@ suite('Events', function () { newValue: 'new value', }), }, - { - title: 'null to Block Marker move', - class: Blockly.Events.MarkerMove, - getArgs: (thisObj) => [ - thisObj.block, - true, - null, - new ASTNode(ASTNode.types.BLOCK, thisObj.block), - ], - getExpectedJson: (thisObj) => ({ - type: 'marker_move', - group: '', - isCursor: true, - blockId: thisObj.block.id, - oldNode: undefined, - newNode: new ASTNode(ASTNode.types.BLOCK, thisObj.block), - }), - }, - { - title: 'null to Workspace Marker move', - class: Blockly.Events.MarkerMove, - getArgs: (thisObj) => [ - null, - true, - null, - ASTNode.createWorkspaceNode( - thisObj.workspace, - new Blockly.utils.Coordinate(0, 0), - ), - ], - getExpectedJson: (thisObj) => ({ - type: 'marker_move', - group: '', - isCursor: true, - blockId: undefined, - oldNode: undefined, - newNode: ASTNode.createWorkspaceNode( - thisObj.workspace, - new Blockly.utils.Coordinate(0, 0), - ), - }), - }, - { - title: 'Workspace to Block Marker move', - class: Blockly.Events.MarkerMove, - getArgs: (thisObj) => [ - thisObj.block, - true, - ASTNode.createWorkspaceNode( - thisObj.workspace, - new Blockly.utils.Coordinate(0, 0), - ), - new ASTNode(ASTNode.types.BLOCK, thisObj.block), - ], - getExpectedJson: (thisObj) => ({ - type: 'marker_move', - group: '', - isCursor: true, - blockId: thisObj.block.id, - oldNode: ASTNode.createWorkspaceNode( - thisObj.workspace, - new Blockly.utils.Coordinate(0, 0), - ), - newNode: new ASTNode(ASTNode.types.BLOCK, thisObj.block), - }), - }, - { - title: 'Block to Workspace Marker move', - class: Blockly.Events.MarkerMove, - getArgs: (thisObj) => [ - null, - true, - new ASTNode(ASTNode.types.BLOCK, thisObj.block), - ASTNode.createWorkspaceNode( - thisObj.workspace, - new Blockly.utils.Coordinate(0, 0), - ), - ], - }, { title: 'Selected', class: Blockly.Events.Selected, @@ -1302,7 +1222,7 @@ suite('Events', function () { new Blockly.Events.BlockChange(block, 'field', 'VAR', 'id1', 'id2'), new Blockly.Events.Click(block), ]; - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); assert.equal(filteredEvents.length, 4); // no event should have been removed. // test that the order hasn't changed assert.isTrue(filteredEvents[0] instanceof Blockly.Events.BlockCreate); @@ -1320,7 +1240,7 @@ suite('Events', function () { new Blockly.Events.BlockCreate(block2), new Blockly.Events.BlockMove(block2), ]; - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); assert.equal(filteredEvents.length, 4); // no event should have been removed. }); @@ -1330,7 +1250,7 @@ suite('Events', function () { addMoveEvent(events, block, 1, 1); addMoveEvent(events, block, 2, 2); addMoveEvent(events, block, 3, 3); - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); assert.equal(filteredEvents.length, 2); // duplicate moves should have been removed. // test that the order hasn't changed assert.isTrue(filteredEvents[0] instanceof Blockly.Events.BlockCreate); @@ -1339,27 +1259,12 @@ suite('Events', function () { assert.equal(filteredEvents[1].newCoordinate.y, 3); }); - test('Backward', function () { - const block = this.workspace.newBlock('field_variable_test_block', '1'); - const events = [new Blockly.Events.BlockCreate(block)]; - addMoveEvent(events, block, 1, 1); - addMoveEvent(events, block, 2, 2); - addMoveEvent(events, block, 3, 3); - const filteredEvents = eventUtils.filter(events, false); - assert.equal(filteredEvents.length, 2); // duplicate event should have been removed. - // test that the order hasn't changed - assert.isTrue(filteredEvents[0] instanceof Blockly.Events.BlockCreate); - assert.isTrue(filteredEvents[1] instanceof Blockly.Events.BlockMove); - assert.equal(filteredEvents[1].newCoordinate.x, 1); - assert.equal(filteredEvents[1].newCoordinate.y, 1); - }); - test('Merge block move events', function () { const block = this.workspace.newBlock('field_variable_test_block', '1'); const events = []; addMoveEvent(events, block, 0, 0); addMoveEvent(events, block, 1, 1); - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); assert.equal(filteredEvents.length, 1); // second move event merged into first assert.equal(filteredEvents[0].newCoordinate.x, 1); assert.equal(filteredEvents[0].newCoordinate.y, 1); @@ -1377,7 +1282,7 @@ suite('Events', function () { 'item2', ), ]; - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); assert.equal(filteredEvents.length, 1); // second change event merged into first assert.equal(filteredEvents[0].oldValue, 'item'); assert.equal(filteredEvents[0].newValue, 'item2'); @@ -1388,7 +1293,7 @@ suite('Events', function () { new Blockly.Events.ViewportChange(1, 2, 3, this.workspace, 4), new Blockly.Events.ViewportChange(5, 6, 7, this.workspace, 8), ]; - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); assert.equal(filteredEvents.length, 1); // second change event merged into first assert.equal(filteredEvents[0].viewTop, 5); assert.equal(filteredEvents[0].viewLeft, 6); @@ -1408,7 +1313,7 @@ suite('Events', function () { new Blockly.Events.BubbleOpen(block3, true, 'warning'), new Blockly.Events.Click(block3), ]; - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); // click event merged into corresponding *Open event assert.equal(filteredEvents.length, 3); assert.isTrue(filteredEvents[0] instanceof Blockly.Events.BubbleOpen); @@ -1427,7 +1332,7 @@ suite('Events', function () { new Blockly.Events.Click(block), new Blockly.Events.BlockDrag(block, true), ]; - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); // click and stackclick should both exist assert.equal(filteredEvents.length, 2); assert.isTrue(filteredEvents[0] instanceof Blockly.Events.Click); @@ -1447,7 +1352,7 @@ suite('Events', function () { const events = []; addMoveEventParent(events, block, null); addMoveEventParent(events, block, null); - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); // The two events should be merged, but because nothing has changed // they will be filtered out. assert.equal(filteredEvents.length, 0); @@ -1468,7 +1373,7 @@ suite('Events', function () { events.push(new Blockly.Events.BlockDelete(block2)); addMoveEvent(events, block1, 2, 2); - const filteredEvents = eventUtils.filter(events, true); + const filteredEvents = eventUtils.filter(events); // Nothing should have merged. assert.equal(filteredEvents.length, 4); // test that the order hasn't changed diff --git a/tests/mocha/event_var_type_change_test.js b/tests/mocha/event_var_type_change_test.js new file mode 100644 index 00000000000..d19b0421a33 --- /dev/null +++ b/tests/mocha/event_var_type_change_test.js @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Var Type Change Event', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = new Blockly.Workspace(); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + suite('Serialization', function () { + test('variable type change events round-trip through JSON', function () { + const varModel = new Blockly.VariableModel( + this.workspace, + 'name', + 'foo', + 'id', + ); + const origEvent = new Blockly.Events.VarTypeChange( + varModel, + 'foo', + 'bar', + ); + + const json = origEvent.toJson(); + const newEvent = new Blockly.Events.fromJson(json, this.workspace); + + assert.deepEqual(newEvent, origEvent); + }); + }); +}); diff --git a/tests/mocha/field_dropdown_test.js b/tests/mocha/field_dropdown_test.js index 84ce92e2861..e9bc159146d 100644 --- a/tests/mocha/field_dropdown_test.js +++ b/tests/mocha/field_dropdown_test.js @@ -92,9 +92,9 @@ suite('Dropdown Fields', function () { expectedText: 'a', args: [ [ - [{src: 'scrA', alt: 'a'}, 'A'], - [{src: 'scrB', alt: 'b'}, 'B'], - [{src: 'scrC', alt: 'c'}, 'C'], + [{src: 'scrA', alt: 'a', width: 10, height: 10}, 'A'], + [{src: 'scrB', alt: 'b', width: 10, height: 10}, 'B'], + [{src: 'scrC', alt: 'c', width: 10, height: 10}, 'C'], ], ], }, @@ -121,9 +121,9 @@ suite('Dropdown Fields', function () { args: [ () => { return [ - [{src: 'scrA', alt: 'a'}, 'A'], - [{src: 'scrB', alt: 'b'}, 'B'], - [{src: 'scrC', alt: 'c'}, 'C'], + [{src: 'scrA', alt: 'a', width: 10, height: 10}, 'A'], + [{src: 'scrB', alt: 'b', width: 10, height: 10}, 'B'], + [{src: 'scrC', alt: 'c', width: 10, height: 10}, 'C'], ]; }, ], diff --git a/tests/mocha/field_textinput_test.js b/tests/mocha/field_textinput_test.js index 7170b27ff62..82c1a645e6d 100644 --- a/tests/mocha/field_textinput_test.js +++ b/tests/mocha/field_textinput_test.js @@ -192,7 +192,7 @@ suite('Text Input Fields', function () { setup(function () { this.prepField = function (field) { const workspace = { - getScale: function () { + getAbsoluteScale: function () { return 1; }, getRenderer: function () { diff --git a/tests/mocha/field_variable_test.js b/tests/mocha/field_variable_test.js index 78dad10bac3..2dc8d35a55c 100644 --- a/tests/mocha/field_variable_test.js +++ b/tests/mocha/field_variable_test.js @@ -309,6 +309,24 @@ suite('Variable Fields', function () { assert.deepEqual(field.variableTypes, ['Type1']); assert.equal(field.defaultType, 'Type1'); }); + test('Empty list of types', function () { + assert.throws(function () { + const fieldVariable = new Blockly.FieldVariable( + 'name1', + undefined, + [], + ); + }); + }); + test('invalid value for list of types', function () { + assert.throws(function () { + const fieldVariable = new Blockly.FieldVariable( + 'name1', + undefined, + 'not an array', + ); + }); + }); test('JSON Definition', function () { const field = Blockly.FieldVariable.fromJson({ variable: 'test', @@ -353,13 +371,6 @@ suite('Variable Fields', function () { this.workspace.createVariable('name1', 'type1'); this.workspace.createVariable('name2', 'type2'); }); - test('variableTypes is undefined', function () { - // Expect that since variableTypes is undefined, only type empty string - // will be returned (regardless of what types are available on the workspace). - const fieldVariable = new Blockly.FieldVariable('name1'); - const resultTypes = fieldVariable.getVariableTypes(); - assert.deepEqual(resultTypes, ['']); - }); test('variableTypes is explicit', function () { // Expect that since variableTypes is defined, it will be the return // value, regardless of what types are available on the workspace. @@ -377,6 +388,17 @@ suite('Variable Fields', function () { 'Default type was wrong', ); }); + test('variableTypes is undefined', function () { + // Expect all variable types in the workspace to be returned, same + // as if variableTypes is null. + const fieldVariable = new Blockly.FieldVariable('name1'); + const mockBlock = createTestBlock(); + mockBlock.workspace = this.workspace; + fieldVariable.setSourceBlock(mockBlock); + + const resultTypes = fieldVariable.getVariableTypes(); + assert.deepEqual(resultTypes, ['type1', 'type2']); + }); test('variableTypes is null', function () { // Expect all variable types to be returned. // The field does not need to be initialized to do this--it just needs @@ -388,19 +410,25 @@ suite('Variable Fields', function () { fieldVariable.variableTypes = null; const resultTypes = fieldVariable.getVariableTypes(); - // The empty string is always one of the options. - assert.deepEqual(resultTypes, ['type1', 'type2', '']); + assert.deepEqual(resultTypes, ['type1', 'type2']); }); - test('variableTypes is the empty list', function () { - const fieldVariable = new Blockly.FieldVariable('name1'); + test('variableTypes is null and variable is in the flyout', function () { + // Expect all variable types in the workspace and + // flyout workspace to be returned. + const fieldVariable = new Blockly.FieldVariable('name1', undefined, null); const mockBlock = createTestBlock(); mockBlock.workspace = this.workspace; + + // Pretend this is a flyout workspace with potential variables + mockBlock.isInFlyout = true; + mockBlock.workspace.createPotentialVariableMap(); + mockBlock.workspace + .getPotentialVariableMap() + .createVariable('name3', 'type3'); fieldVariable.setSourceBlock(mockBlock); - fieldVariable.variableTypes = []; - assert.throws(function () { - fieldVariable.getVariableTypes(); - }); + const resultTypes = fieldVariable.getVariableTypes(); + assert.deepEqual(resultTypes, ['type1', 'type2', 'type3']); }); }); suite('Default types', function () { diff --git a/tests/mocha/flyout_test.js b/tests/mocha/flyout_test.js index 9be45458c51..f6d3019df55 100644 --- a/tests/mocha/flyout_test.js +++ b/tests/mocha/flyout_test.js @@ -309,16 +309,12 @@ suite('Flyout', function () { function checkFlyoutInfo(flyoutSpy) { const flyoutInfo = flyoutSpy.returnValues[0]; - const contents = flyoutInfo.contents; - const gaps = flyoutInfo.gaps; + const contents = flyoutInfo; - const expectedGaps = [20, 24, 24]; - assert.deepEqual(gaps, expectedGaps); - - assert.equal(contents.length, 3, 'Contents'); + assert.equal(contents.length, 6, 'Contents'); assert.equal(contents[0].type, 'block', 'Contents'); - const block = contents[0]['block']; + const block = contents[0]['element']; assert.instanceOf(block, Blockly.BlockSvg); assert.equal(block.getFieldValue('OP'), 'NEQ'); const childA = block.getInputTargetBlock('A'); @@ -328,11 +324,20 @@ suite('Flyout', function () { assert.equal(childA.getFieldValue('NUM'), 1); assert.equal(childB.getFieldValue('NUM'), 2); - assert.equal(contents[1].type, 'button', 'Contents'); - assert.instanceOf(contents[1]['button'], Blockly.FlyoutButton); + assert.equal(contents[1].type, 'sep'); + assert.equal(contents[1].element.getBoundingRectangle().getHeight(), 20); assert.equal(contents[2].type, 'button', 'Contents'); - assert.instanceOf(contents[2]['button'], Blockly.FlyoutButton); + assert.instanceOf(contents[2]['element'], Blockly.FlyoutButton); + + assert.equal(contents[3].type, 'sep'); + assert.equal(contents[3].element.getBoundingRectangle().getHeight(), 24); + + assert.equal(contents[4].type, 'label', 'Contents'); + assert.instanceOf(contents[4]['element'], Blockly.FlyoutButton); + + assert.equal(contents[5].type, 'sep'); + assert.equal(contents[5].element.getBoundingRectangle().getHeight(), 24); } suite('Direct show', function () { @@ -621,35 +626,5 @@ suite('Flyout', function () { const block = this.flyout.workspace_.getAllBlocks()[0]; assert.equal(block.getFieldValue('NUM'), 321); }); - - test('Recycling enabled', function () { - this.flyout.blockIsRecyclable_ = function () { - return true; - }; - this.flyout.show({ - 'contents': [ - { - 'kind': 'BLOCK', - 'type': 'math_number', - 'fields': { - 'NUM': 123, - }, - }, - ], - }); - this.flyout.show({ - 'contents': [ - { - 'kind': 'BLOCK', - 'type': 'math_number', - 'fields': { - 'NUM': 321, - }, - }, - ], - }); - const block = this.flyout.workspace_.getAllBlocks()[0]; - assert.equal(block.getFieldValue('NUM'), 123); - }); }); }); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js new file mode 100644 index 00000000000..b1cfb029a87 --- /dev/null +++ b/tests/mocha/focus_manager_test.js @@ -0,0 +1,5186 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + FocusManager, + getFocusManager, +} from '../../build/src/core/focus_manager.js'; +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +class FocusableNodeImpl { + constructor(element, tree) { + this.element = element; + this.tree = tree; + } + + getFocusableElement() { + return this.element; + } + + getFocusableTree() { + return this.tree; + } + + onNodeFocus() {} + + onNodeBlur() {} + + canBeFocused() { + return true; + } +} + +class FocusableTreeImpl { + constructor(rootElement, nestedTrees) { + this.nestedTrees = nestedTrees; + this.idToNodeMap = {}; + this.rootNode = this.addNode(rootElement); + this.fallbackNode = null; + } + + addNode(element) { + const node = new FocusableNodeImpl(element, this); + this.idToNodeMap[element.id] = node; + return node; + } + + removeNode(node) { + delete this.idToNodeMap[node.getFocusableElement().id]; + } + + getRootFocusableNode() { + return this.rootNode; + } + + getRestoredFocusableNode() { + return this.fallbackNode; + } + + getNestedTrees() { + return this.nestedTrees; + } + + lookUpFocusableNode(id) { + return this.idToNodeMap[id]; + } + + onTreeFocus() {} + + onTreeBlur() {} +} + +suite('FocusManager', function () { + const ACTIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME}`; + const PASSIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME}`; + + const createFocusableTree = function (rootElementId, nestedTrees) { + return new FocusableTreeImpl( + document.getElementById(rootElementId), + nestedTrees || [], + ); + }; + const createFocusableNode = function (tree, elementId) { + return tree.addNode(document.getElementById(elementId)); + }; + + setup(function () { + sharedTestSetup.call(this); + + this.focusManager = getFocusManager(); + + this.testFocusableTree1 = createFocusableTree('testFocusableTree1'); + this.testFocusableTree1Node1 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node1', + ); + this.testFocusableTree1Node1Child1 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node1.child1', + ); + this.testFocusableTree1Node2 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node2', + ); + this.testFocusableNestedTree4 = createFocusableTree( + 'testFocusableNestedTree4', + ); + this.testFocusableNestedTree4Node1 = createFocusableNode( + this.testFocusableNestedTree4, + 'testFocusableNestedTree4.node1', + ); + this.testFocusableNestedTree5 = createFocusableTree( + 'testFocusableNestedTree5', + ); + this.testFocusableNestedTree5Node1 = createFocusableNode( + this.testFocusableNestedTree5, + 'testFocusableNestedTree5.node1', + ); + this.testFocusableTree2 = createFocusableTree('testFocusableTree2', [ + this.testFocusableNestedTree4, + this.testFocusableNestedTree5, + ]); + this.testFocusableTree2Node1 = createFocusableNode( + this.testFocusableTree2, + 'testFocusableTree2.node1', + ); + + this.testFocusableGroup1 = createFocusableTree('testFocusableGroup1'); + this.testFocusableGroup1Node1 = createFocusableNode( + this.testFocusableGroup1, + 'testFocusableGroup1.node1', + ); + this.testFocusableGroup1Node1Child1 = createFocusableNode( + this.testFocusableGroup1, + 'testFocusableGroup1.node1.child1', + ); + this.testFocusableGroup1Node2 = createFocusableNode( + this.testFocusableGroup1, + 'testFocusableGroup1.node2', + ); + this.testFocusableNestedGroup4 = createFocusableTree( + 'testFocusableNestedGroup4', + ); + this.testFocusableNestedGroup4Node1 = createFocusableNode( + this.testFocusableNestedGroup4, + 'testFocusableNestedGroup4.node1', + ); + this.testFocusableGroup2 = createFocusableTree('testFocusableGroup2', [ + this.testFocusableNestedGroup4, + ]); + this.testFocusableGroup2Node1 = createFocusableNode( + this.testFocusableGroup2, + 'testFocusableGroup2.node1', + ); + }); + + teardown(function () { + sharedTestTeardown.call(this); + + // Ensure all node CSS styles are reset so that state isn't leaked between tests. + const activeElems = document.querySelectorAll( + ACTIVE_FOCUS_NODE_CSS_SELECTOR, + ); + const passiveElems = document.querySelectorAll( + PASSIVE_FOCUS_NODE_CSS_SELECTOR, + ); + for (const elem of activeElems) { + elem.classList.remove(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + for (const elem of passiveElems) { + elem.classList.remove(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + } + + // Reset the current active element. + document.body.focus(); + }); + + assert.includesClass = function (classList, className) { + assert.isTrue( + classList.contains(className), + 'Expected class list to include: ' + className, + ); + }; + + assert.notIncludesClass = function (classList, className) { + assert.isFalse( + classList.contains(className), + 'Expected class list to not include: ' + className, + ); + }; + + /* Basic lifecycle tests. */ + + suite('registerTree()', function () { + test('once does not throw', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + // The test should pass due to no exception being thrown. + }); + + test('twice for same tree throws error', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + const errorMsgRegex = + /Attempted to re-register already registered tree.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + }); + + test('twice with different trees does not throw', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableGroup1); + + // The test shouldn't throw since two different trees were registered. + }); + + test('register after an unregister does not throw', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + + this.focusManager.registerTree(this.testFocusableTree1); + + // The second register should not fail since the tree was previously unregistered. + }); + }); + + suite('unregisterTree()', function () { + test('for not yet registered tree throws', function () { + const errorMsgRegex = /Attempted to unregister not registered tree.+?/; + assert.throws( + () => this.focusManager.unregisterTree(this.testFocusableTree1), + errorMsgRegex, + ); + }); + + test('for registered tree does not throw', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering a registered tree should not fail. + }); + + test('twice for registered tree throws', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + + const errorMsgRegex = /Attempted to unregister not registered tree.+?/; + assert.throws( + () => this.focusManager.unregisterTree(this.testFocusableTree1), + errorMsgRegex, + ); + }); + }); + + suite('isRegistered()', function () { + test('for not registered tree returns false', function () { + const isRegistered = this.focusManager.isRegistered( + this.testFocusableTree1, + ); + + assert.isFalse(isRegistered); + }); + + test('for registered tree returns true', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + const isRegistered = this.focusManager.isRegistered( + this.testFocusableTree1, + ); + + assert.isTrue(isRegistered); + }); + + test('for unregistered tree returns false', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + + const isRegistered = this.focusManager.isRegistered( + this.testFocusableTree1, + ); + + assert.isFalse(isRegistered); + }); + + test('for re-registered tree returns true', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree1); + + const isRegistered = this.focusManager.isRegistered( + this.testFocusableTree1, + ); + + assert.isTrue(isRegistered); + }); + + test('for unregistered tree with other registered tree returns false', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + + const isRegistered = this.focusManager.isRegistered( + this.testFocusableTree1, + ); + + assert.isFalse(isRegistered); + }); + }); + + suite('getFocusedTree()', function () { + test('by default returns null', function () { + const focusedTree = this.focusManager.getFocusedTree(); + + assert.isNull(focusedTree); + }); + }); + + suite('getFocusedNode()', function () { + test('by default returns null', function () { + const focusedNode = this.focusManager.getFocusedNode(); + + assert.isNull(focusedNode); + }); + }); + + suite('focusTree()', function () { + test('for not registered tree throws', function () { + const errorMsgRegex = /Attempted to focus unregistered tree.+?/; + assert.throws( + () => this.focusManager.focusTree(this.testFocusableTree1), + errorMsgRegex, + ); + }); + + test('for unregistered tree throws', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + + const errorMsgRegex = /Attempted to focus unregistered tree.+?/; + assert.throws( + () => this.focusManager.focusTree(this.testFocusableTree1), + errorMsgRegex, + ); + }); + }); + + suite('focusNode()', function () { + test('for not registered node throws', function () { + const errorMsgRegex = /Attempted to focus unregistered node.+?/; + assert.throws( + () => this.focusManager.focusNode(this.testFocusableTree1Node1), + errorMsgRegex, + ); + }); + + test('for unregistered node throws', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.unregisterTree(this.testFocusableTree1); + + const errorMsgRegex = /Attempted to focus unregistered node.+?/; + assert.throws( + () => this.focusManager.focusNode(this.testFocusableTree1Node1), + errorMsgRegex, + ); + }); + + test('focuses element', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('fires focusin event', function () { + let focusCount = 0; + const focusListener = () => focusCount++; + document.addEventListener('focusin', focusListener); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + document.removeEventListener('focusin', focusListener); + + // There should be exactly 1 focus event fired from focusNode(). + assert.strictEqual(focusCount, 1); + }); + + test('for orphaned node returns tree root by default', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.testFocusableTree1.removeNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing an invalid node should fall back to the tree root when it has no restoration + // fallback node. + const currentNode = this.focusManager.getFocusedNode(); + const treeRoot = this.testFocusableTree1.getRootFocusableNode(); + assert.strictEqual(currentNode, treeRoot); + }); + + test('for orphaned node returns specified fallback node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.testFocusableTree1.fallbackNode = this.testFocusableTree1Node2; + this.testFocusableTree1.removeNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing an invalid node should fall back to the restored fallback. + const currentNode = this.focusManager.getFocusedNode(); + assert.strictEqual(currentNode, this.testFocusableTree1Node2); + }); + }); + + suite('getFocusManager()', function () { + test('returns non-null manager', function () { + const manager = getFocusManager(); + + assert.isNotNull(manager); + }); + + test('returns the exact same instance in subsequent calls', function () { + const manager1 = getFocusManager(); + const manager2 = getFocusManager(); + + assert.strictEqual(manager2, manager1); + }); + }); + + /* Focus tests for HTML trees. */ + + suite('focus*() switching in HTML tree', function () { + suite('getFocusedTree()', function () { + test('registered tree focusTree()ed no prev focus returns tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusTree(this.testFocusableTree1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test('registered tree focusTree()ed prev node focused returns tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusTree(this.testFocusableTree1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test('registered tree focusTree()ed diff tree prev focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusTree(this.testFocusableTree2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test('registered tree focusTree()ed diff tree node prev focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusTree(this.testFocusableTree2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test('registered root focusNode()ed no prev focus returns tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode( + this.testFocusableTree1.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test("registered node focusNode()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test("registered subnode focusNode()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1Child1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test('registered node focusNode()ed after prev node focus returns same tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test("registered node focusNode()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test("registered tree root focusNode()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode( + this.testFocusableTree2.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test('unregistered tree focusTree()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focusNode()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focusNode()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focusNode()ed with prev node recently focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusTree(this.testFocusableNestedTree4); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + }); + suite('getFocusedNode()', function () { + test('registered tree focusTree()ed no prev focus returns root node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusTree(this.testFocusableTree1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1.getRootFocusableNode(), + ); + }); + + test('registered tree focusTree()ed prev node focused returns original node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusTree(this.testFocusableTree1); + + // The original node retains focus since the tree already holds focus (per focusTree's + // contract). + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node1, + ); + }); + + test('registered tree focusTree()ed diff tree prev focused returns new root node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusTree(this.testFocusableTree2); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2.getRootFocusableNode(), + ); + }); + + test('registered tree focusTree()ed diff tree node prev focused returns new root node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusTree(this.testFocusableTree2); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2.getRootFocusableNode(), + ); + }); + + test('registered root focusNode()ed no prev focus returns root node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode( + this.testFocusableTree1.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1.getRootFocusableNode(), + ); + }); + + test('registered node focusNode()ed no prev focus returns node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node1, + ); + }); + + test('registered subnode focusNode()ed no prev focus returns subnode', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1Child1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node1Child1, + ); + }); + + test('registered node focusNode()ed after prev node focus returns new node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); + + test('registered node focusNode()ed after prev node focus diff tree returns new node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + }); + + test('registered tree root focusNode()ed after prev node focus diff tree returns new root', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode( + this.testFocusableTree2.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2.getRootFocusableNode(), + ); + }); + + test('unregistered tree focusTree()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focusNode()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focusNode()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focusNode()ed with prev node recently focused returns new node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + }); + + test('nested tree focusTree()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusTree(this.testFocusableNestedTree4); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4.getRootFocusableNode(), + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); + + test('deletion after focusNode() returns null', function () { + const rootElem = document.createElement('div'); + const nodeElem = document.createElement('div'); + rootElem.setAttribute('id', 'focusRoot'); + rootElem.setAttribute('tabindex', '-1'); + nodeElem.setAttribute('id', 'focusNode'); + nodeElem.setAttribute('tabindex', '-1'); + nodeElem.textContent = 'Focusable node'; + rootElem.appendChild(nodeElem); + document.body.appendChild(rootElem); + const root = createFocusableTree('focusRoot'); + const node = createFocusableNode(root, 'focusNode'); + this.focusManager.registerTree(root); + this.focusManager.focusNode(node); + + node.getFocusableElement().remove(); + + assert.notStrictEqual(this.focusManager.getFocusedNode(), node); + rootElem.remove(); // Cleanup. + }); + }); + suite('CSS classes', function () { + test('registered tree focusTree()ed no prev focus root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusTree(this.testFocusableTree1); + + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed prev node focused original elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusTree(this.testFocusableTree1); + + // The original node retains active focus since the tree already holds focus (per + // focusTree's contract). + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed diff tree prev focused new root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusTree(this.testFocusableTree2); + + const rootElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed diff tree node prev focused new root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusTree(this.testFocusableTree2); + + const rootElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered root focusNode()ed no prev focus returns root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode( + this.testFocusableTree1.getRootFocusableNode(), + ); + + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed no prev focus node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus same tree old node elem has no focus property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + const prevNodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus same tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + const newNodeElem = this.testFocusableTree1Node2.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus diff tree old node elem has passive property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + const prevNodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus diff tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + const newNodeElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree root focusNode()ed after prev node focus diff tree new root has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode( + this.testFocusableTree2.getRootFocusableNode(), + ); + + const rootElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusTree()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusNode()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusNode()ed with prev node prior removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. However, the old node + // should still have passive indication. + const otherNodeElem = + this.testFocusableTree2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusNode()ed with prev node recently removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. However, the new node + // should still have active indication. + const otherNodeElem = + this.testFocusableTree2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focusNode() multiple nodes in same tree with switches ensure passive focus has gone', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + // When switching back to the first tree, ensure the original passive node is no longer + // passive now that the new node is active. + const node1 = this.testFocusableTree1Node1.getFocusableElement(); + const node2 = this.testFocusableTree1Node2.getFocusableElement(); + assert.notIncludesClass( + node1.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + node2.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed other tree node passively focused tree node now has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusTree(this.testFocusableTree1); + + // The original node in the tree should be moved from passive to active focus per the + // contract of focusTree). Also, the root of the tree should have no focus indication. + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree focusTree()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusTree(this.testFocusableNestedTree4); + + const rootElem = this.testFocusableNestedTree4 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focusNode()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + const nodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focusNode()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + const prevNodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + currNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + }); + + suite('DOM focus() switching in HTML tree', function () { + suite('getFocusedTree()', function () { + test('registered root focus()ed no prev focus returns tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test("registered node focus()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test("registered subnode focus()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1.child1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test('registered node focus()ed after prev node focus returns same tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree1.node2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test("registered node focus()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test("registered tree root focus()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test("non-registered node subelement focus()ed returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document + .getElementById('testFocusableTree1.node2.unregisteredChild1') + .focus(); + + // The tree of the unregistered child element should take focus. + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3').focus(); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('non-registered tree node focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('non-registered tree node focus()ed after registered node focused returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unfocusable element focus()ed after registered node focused returns original tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree1, + ); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').focus(); + document.getElementById('testFocusableTree1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with prev node recently focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + }); + + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + this.focusManager.unregisterTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1').focus(); + + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + }); + }); + suite('getFocusedNode()', function () { + test('registered root focus()ed no prev focus returns root node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1.getRootFocusableNode(), + ); + }); + + test('registered node focus()ed no prev focus returns node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node1, + ); + }); + + test('registered subnode focus()ed no prev focus returns subnode', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1.child1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node1Child1, + ); + }); + + test('registered node focus()ed after prev node focus returns new node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree1.node2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); + + test('registered node focus()ed after prev node focus diff tree returns new node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + }); + + test('registered tree root focus()ed after prev node focus diff tree returns new root', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2.getRootFocusableNode(), + ); + }); + + test('non-registered node subelement focus()ed returns nearest node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document + .getElementById('testFocusableTree1.node2.unregisteredChild1') + .focus(); + + // The nearest node of the unregistered child element should take focus. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); + + test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('non-registered tree node focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('non-registered tree node focus()ed after registered node focused returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unfocusable element focus()ed after registered node focused returns original node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node1, + ); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focus()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').focus(); + document.getElementById('testFocusableTree1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focus()ed with prev node recently focused returns new node', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + }); + + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + this.focusManager.unregisterTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1').focus(); + + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('nested tree focus()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4.getRootFocusableNode(), + ); + }); + + test('nested tree node focus()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); + + test('nested tree node focus()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + }); + + test('deletion after focus() returns null', function () { + const rootElem = document.createElement('div'); + const nodeElem = document.createElement('div'); + rootElem.setAttribute('id', 'focusRoot'); + rootElem.setAttribute('tabindex', '-1'); + nodeElem.setAttribute('id', 'focusNode'); + nodeElem.setAttribute('tabindex', '-1'); + nodeElem.textContent = 'Focusable node'; + rootElem.appendChild(nodeElem); + document.body.appendChild(rootElem); + const root = createFocusableTree('focusRoot'); + const node = createFocusableNode(root, 'focusNode'); + this.focusManager.registerTree(root); + document.getElementById('focusNode').focus(); + + node.getFocusableElement().remove(); + + assert.notStrictEqual(this.focusManager.getFocusedNode(), node); + rootElem.remove(); // Cleanup. + }); + }); + suite('CSS classes', function () { + test('registered root focus()ed no prev focus returns root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1').focus(); + + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed no prev focus node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1').focus(); + + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus same tree old node elem has no focus property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree1.node2').focus(); + + const prevNodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus same tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree1.node2').focus(); + + const newNodeElem = this.testFocusableTree1Node2.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus diff tree old node elem has passive property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + const prevNodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus diff tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + const newNodeElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree root focus()ed after prev node focus diff tree new root has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testFocusableTree2').focus(); + + const rootElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('non-registered node subelement focus()ed nearest node has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + document + .getElementById('testFocusableTree1.node2.unregisteredChild1') + .focus(); + + // The nearest node of the unregistered child element should be actively focused. + const nodeElem = this.testFocusableTree1Node2.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('non-registered tree focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableTree3').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + + const rootElem = document.getElementById( + 'testUnregisteredFocusableTree3', + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('non-registered tree node focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + + const nodeElem = document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unfocsable element focus()ed after registered node focused original node has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + + // The original node should be unchanged, and the unregistered node should not have any + // focus indicators. + const nodeElem = document.getElementById('testFocusableTree1.node1'); + const attemptedNewNodeElem = document.getElementById( + 'testUnfocusableElement', + ); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + attemptedNewNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + attemptedNewNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with prev node prior removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').focus(); + document.getElementById('testFocusableTree1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. However, the old node + // should still have passive indication. + const otherNodeElem = + this.testFocusableTree2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with prev node recently removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Since the tree was unregistered it no longer has focus indicators. However, the new node + // should still have active indication. + const otherNodeElem = + this.testFocusableTree2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + this.focusManager.unregisterTree(this.testFocusableTree1); + + document.getElementById('testFocusableTree1.node1').focus(); + + // Attempting to focus a now removed tree should remove active. + const otherNodeElem = + this.testFocusableTree2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableTree1Node1.getFocusableElement(); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focus() multiple nodes in same tree with switches ensure passive focus has gone', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableTree1.node2').focus(); + + // When switching back to the first tree, ensure the original passive node is no longer + // passive now that the new node is active. + const node1 = this.testFocusableTree1Node1.getFocusableElement(); + const node2 = this.testFocusableTree1Node2.getFocusableElement(); + assert.notIncludesClass( + node1.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + node2.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focus()ed other tree node passively focused tree node now has active property', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableTree1').focus(); + + // Directly refocusing a tree's root should have functional parity with focusTree(). That + // means the tree's previous node should now have active focus again and its root should + // have no focus indication. + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1').focus(); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableTree1.node1').focus(); + + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + const rootElem = this.testFocusableTree1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree focus()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4').focus(); + + const rootElem = this.testFocusableNestedTree4 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focus()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + const nodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focus()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + const prevNodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + currNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + }); + + /* Focus tests for SVG trees. */ + + suite('focus*() switching in SVG tree', function () { + suite('getFocusedTree()', function () { + test('registered tree focusTree()ed no prev focus returns tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test('registered tree focusTree()ed prev node focused returns tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test('registered tree focusTree()ed diff tree prev focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test('registered tree focusTree()ed diff tree node prev focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test('registered root focusNode()ed no prev focus returns tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode( + this.testFocusableGroup1.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test("registered node focusNode()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test("registered subnode focusNode()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode(this.testFocusableGroup1Node1Child1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test('registered node focusNode()ed after prev node focus returns same tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup1Node2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test("registered node focusNode()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test("registered tree root focusNode()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode( + this.testFocusableGroup2.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test('unregistered tree focusTree()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusTree(this.testFocusableGroup1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focusNode()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focusNode()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focusNode()ed with prev node recently focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusTree(this.testFocusableNestedGroup4); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + }); + suite('getFocusedNode()', function () { + test('registered tree focusTree()ed no prev focus returns root node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1.getRootFocusableNode(), + ); + }); + + test('registered tree focusTree()ed prev node focused returns original node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + // The original node retains focus since the tree already holds focus (per focusTree's + // contract). + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node1, + ); + }); + + test('registered tree focusTree()ed diff tree prev focused returns new root node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2.getRootFocusableNode(), + ); + }); + + test('registered tree focusTree()ed diff tree node prev focused returns new root node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2.getRootFocusableNode(), + ); + }); + + test('registered root focusNode()ed no prev focus returns root node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode( + this.testFocusableGroup1.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1.getRootFocusableNode(), + ); + }); + + test('registered node focusNode()ed no prev focus returns node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node1, + ); + }); + + test('registered subnode focusNode()ed no prev focus returns subnode', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode(this.testFocusableGroup1Node1Child1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node1Child1, + ); + }); + + test('registered node focusNode()ed after prev node focus returns new node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup1Node2); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); + + test('registered node focusNode()ed after prev node focus diff tree returns new node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + }); + + test('registered tree root focusNode()ed after prev node focus diff tree returns new root', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode( + this.testFocusableGroup2.getRootFocusableNode(), + ); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2.getRootFocusableNode(), + ); + }); + + test('unregistered tree focusTree()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusTree(this.testFocusableGroup1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focusNode()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focusNode()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focusNode()ed with prev node recently focused returns new node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + }); + + test('nested tree focusTree()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusTree(this.testFocusableNestedGroup4); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4.getRootFocusableNode(), + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); + }); + suite('CSS classes', function () { + test('registered tree focusTree()ed no prev focus root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed prev node focused original elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + // The original node retains active focus since the tree already holds focus (per + // focusTree's contract). + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed diff tree prev focused new root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + const rootElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed diff tree node prev focused new root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + const rootElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered root focusNode()ed no prev focus returns root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode( + this.testFocusableGroup1.getRootFocusableNode(), + ); + + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed no prev focus node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus same tree old node elem has no focus property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup1Node2); + + const prevNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus same tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup1Node2); + + const newNodeElem = this.testFocusableGroup1Node2.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus diff tree old node elem has passive property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + const prevNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focusNode()ed after prev node focus diff tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + const newNodeElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree root focusNode()ed after prev node focus diff tree new root has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.focusNode( + this.testFocusableGroup2.getRootFocusableNode(), + ); + + const rootElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusTree()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusTree(this.testFocusableGroup1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusNode()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusNode()ed with prev node prior removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. However, the old node + // should still have passive indication. + const otherNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focusNode()ed with prev node recently removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. However, the new node + // should still have active indication. + const otherNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focusNode() multiple nodes in same tree with switches ensure passive focus has gone', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableGroup1Node2); + + // When switching back to the first tree, ensure the original passive node is no longer + // passive now that the new node is active. + const node1 = this.testFocusableGroup1Node1.getFocusableElement(); + const node2 = this.testFocusableGroup1Node2.getFocusableElement(); + assert.notIncludesClass( + node1.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + node2.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focusTree()ed other tree node passively focused tree node now has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusTree(this.testFocusableGroup1); + + // The original node in the tree should be moved from passive to active focus per the + // contract of focusTree). Also, the root of the tree should have no focus indication. + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree focusTree()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusTree(this.testFocusableNestedGroup4); + + const rootElem = this.testFocusableNestedGroup4 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focusNode()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + const nodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focusNode()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableNestedGroup4Node1); + + const prevNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + currNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + }); + + suite('DOM focus() switching in SVG tree', function () { + suite('getFocusedTree()', function () { + test('registered root focus()ed no prev focus returns tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test("registered node focus()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test("registered subnode focus()ed no prev focus returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1.child1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test('registered node focus()ed after prev node focus returns same tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup1.node2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test("registered node focus()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test("registered tree root focus()ed after prev node focus diff tree returns new node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test("non-registered node subelement focus()ed returns node's tree", function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document + .getElementById('testFocusableGroup1.node2.unregisteredChild1') + .focus(); + + // The tree of the unregistered child element should take focus. + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup1, + ); + }); + + test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableGroup3').focus(); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('non-registered tree node focus()ed returns null', function () { + document + .getElementById('testUnregisteredFocusableGroup3.node1') + .focus(); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('non-registered tree node focus()ed after registered node focused returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document + .getElementById('testUnregisteredFocusableGroup3.node1') + .focus(); + + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').focus(); + document.getElementById('testFocusableGroup1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('unregistered tree focus()ed with prev node recently focused returns new tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + }); + + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + this.focusManager.unregisterTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1').focus(); + + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedTree()); + }); + + test('nested tree focusTree()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + + test('nested tree node focusNode()ed after parent focused returns nested tree', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedGroup4, + ); + }); + }); + suite('getFocusedNode()', function () { + test('registered root focus()ed no prev focus returns root node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1.getRootFocusableNode(), + ); + }); + + test('registered node focus()ed no prev focus returns node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node1, + ); + }); + + test('registered subnode focus()ed no prev focus returns subnode', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1.child1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node1Child1, + ); + }); + + test('registered node focus()ed after prev node focus returns new node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup1.node2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); + + test('registered node focus()ed after prev node focus diff tree returns new node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + }); + + test('registered tree root focus()ed after prev node focus diff tree returns new root', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2.getRootFocusableNode(), + ); + }); + + test('non-registered node subelement focus()ed returns nearest node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document + .getElementById('testFocusableGroup1.node2.unregisteredChild1') + .focus(); + + // The nearest node of the unregistered child element should take focus. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); + + test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableGroup3').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('non-registered tree node focus()ed returns null', function () { + document + .getElementById('testUnregisteredFocusableGroup3.node1') + .focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('non-registered tree node focus()ed after registered node focused returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document + .getElementById('testUnregisteredFocusableGroup3.node1') + .focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unfocusable element focus()ed after registered node focused returns original node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node1, + ); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focus()ed with no prev focus returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focus()ed with prev node prior focused returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').focus(); + document.getElementById('testFocusableGroup1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the more recent tree was removed, there's no tree currently focused. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('unregistered tree focus()ed with prev node recently focused returns new node', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the most recent tree still exists, it still has focus. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + }); + + test('unregistered tree focus()ed with prev node after unregistering returns null', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + this.focusManager.unregisterTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1').focus(); + + // Attempting to focus a now removed tree should result in nothing being + // focused since the removed tree can have DOM focus, but that focus is + // ignored by FocusManager. + assert.isNull(this.focusManager.getFocusedNode()); + }); + + test('nested tree focus()ed with no prev focus returns nested root', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4.getRootFocusableNode(), + ); + }); + + test('nested tree node focus()ed with no prev focus returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); + + test('nested tree node focus()ed after parent focused returns focused node', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedGroup4Node1, + ); + }); + }); + suite('CSS classes', function () { + test('registered root focus()ed no prev focus returns root elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1').focus(); + + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed no prev focus node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1').focus(); + + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus same tree old node elem has no focus property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup1.node2').focus(); + + const prevNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus same tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup1.node2').focus(); + + const newNodeElem = this.testFocusableGroup1Node2.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus diff tree old node elem has passive property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2.node1').focus(); + + const prevNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered node focus()ed after prev node focus diff tree new node elem has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2.node1').focus(); + + const newNodeElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.includesClass( + newNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + newNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree root focus()ed after prev node focus diff tree new root has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testFocusableGroup2').focus(); + + const rootElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('non-registered node subelement focus()ed nearest node has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + + document + .getElementById('testFocusableGroup1.node2.unregisteredChild1') + .focus(); + + // The nearest node of the unregistered child element should be actively focused. + const nodeElem = this.testFocusableGroup1Node2.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('non-registered tree focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableGroup3').focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + + const rootElem = document.getElementById( + 'testUnregisteredFocusableGroup3', + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('non-registered tree node focus()ed has no focus', function () { + document + .getElementById('testUnregisteredFocusableGroup3.node1') + .focus(); + + assert.isNull(this.focusManager.getFocusedNode()); + + const nodeElem = document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unfocusable element focus()ed after registered node focused original node has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + document.getElementById('testUnfocusableElement').focus(); + + // The original node should be unchanged, and the unregistered node should not have any + // focus indicators. + const nodeElem = document.getElementById('testFocusableGroup1.node1'); + const attemptedNewNodeElem = document.getElementById( + 'testUnfocusableElement', + ); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + attemptedNewNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + attemptedNewNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with no prev focus removes focus', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with prev node prior removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').focus(); + document.getElementById('testFocusableGroup1.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. However, the old node + // should still have passive indication. + const otherNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with prev node recently removes focus from removed tree', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + + this.focusManager.unregisterTree(this.testFocusableGroup1); + + // Since the tree was unregistered it no longer has focus indicators. However, the new node + // should still have active indication. + const otherNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + this.focusManager.unregisterTree(this.testFocusableGroup1); + + document.getElementById('testFocusableGroup1.node1').focus(); + + // Attempting to focus a now removed tree should remove active. + const otherNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const removedNodeElem = + this.testFocusableGroup1Node1.getFocusableElement(); + assert.notIncludesClass( + otherNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + otherNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + removedNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focus() multiple nodes in same tree with switches ensure passive focus has gone', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableGroup1.node2').focus(); + + // When switching back to the first tree, ensure the original passive node is no longer + // passive now that the new node is active. + const node1 = this.testFocusableGroup1Node1.getFocusableElement(); + const node2 = this.testFocusableGroup1Node2.getFocusableElement(); + assert.notIncludesClass( + node1.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + node2.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('registered tree focus()ed other tree node passively focused tree node now has active property', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableGroup1').focus(); + + // Directly refocusing a tree's root should have functional parity with focusTree(). That + // means the tree's previous node should now have active focus again and its root should + // have no focus indication. + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1').focus(); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableGroup1.node1').focus(); + + const nodeElem = this.testFocusableGroup1Node1.getFocusableElement(); + const rootElem = this.testFocusableGroup1 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree focus()ed with no prev root has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4').focus(); + + const rootElem = this.testFocusableNestedGroup4 + .getRootFocusableNode() + .getFocusableElement(); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focus()ed with no prev focus node has active focus', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + const nodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('nested tree node focus()ed after parent focused prev has passive node has active', function () { + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableNestedGroup4.node1').focus(); + + const prevNodeElem = + this.testFocusableGroup2Node1.getFocusableElement(); + const currNodeElem = + this.testFocusableNestedGroup4Node1.getFocusableElement(); + assert.notIncludesClass( + prevNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + currNodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currNodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + }); + + /* High-level focus/defocusing tests. */ + suite('Defocusing and refocusing', function () { + test('Defocusing actively focused root HTML tree switches to passive highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree2); + + document.getElementById('testUnregisteredFocusableTree3').focus(); + + const rootNode = this.testFocusableTree2.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.isNull(this.focusManager.getFocusedTree()); + assert.isNull(this.focusManager.getFocusedNode()); + assert.includesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('Defocusing actively focused HTML tree node switches to passive highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + document.getElementById('testUnregisteredFocusableTree3').focus(); + + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.isNull(this.focusManager.getFocusedTree()); + assert.isNull(this.focusManager.getFocusedNode()); + assert.includesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('Defocusing actively focused HTML subtree node switches to passive highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + + document.getElementById('testUnregisteredFocusableTree3').focus(); + + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.isNull(this.focusManager.getFocusedTree()); + assert.isNull(this.focusManager.getFocusedNode()); + assert.includesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('Refocusing actively focused root HTML tree restores to active highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testUnregisteredFocusableTree3').focus(); + + document.getElementById('testFocusableTree2').focus(); + + const rootNode = this.testFocusableTree2.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + assert.strictEqual(this.focusManager.getFocusedNode(), rootNode); + assert.notIncludesClass( + rootElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('Refocusing actively focused HTML tree node restores to active highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testUnregisteredFocusableTree3').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('Refocusing actively focused HTML subtree node restores to active highlight', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableNestedTree4); + this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + document.getElementById('testUnregisteredFocusableTree3').focus(); + + document.getElementById('testFocusableNestedTree4.node1').focus(); + + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableNestedTree4, + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableNestedTree4Node1, + ); + assert.notIncludesClass( + nodeElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + + /* Combined HTML/SVG tree focus tests. */ + + suite('HTML/SVG focus tree switching', function () { + suite('Focus HTML tree then SVG tree', function () { + test('HTML focusTree()ed then SVG focusTree()ed correctly updates getFocusedTree() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableTree2); + + this.focusManager.focusTree(this.testFocusableGroup2); + + const prevElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + const currElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML focusTree()ed then SVG focusNode()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableTree2); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + const prevElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + const currElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML focusTree()ed then SVG DOM focus()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableTree2); + + document.getElementById('testFocusableGroup2.node1').focus(); + + const prevElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + const currElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML focusNode()ed then SVG focusTree()ed correctly updates getFocusedTree() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusTree(this.testFocusableGroup2); + + const prevElem = this.testFocusableTree2Node1.getFocusableElement(); + const currElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML focusNode()ed then SVG focusNode()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + const prevElem = this.testFocusableTree2Node1.getFocusableElement(); + const currElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML focusNode()ed then SVG DOM focus()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + document.getElementById('testFocusableGroup2.node1').focus(); + + const prevElem = this.testFocusableTree2Node1.getFocusableElement(); + const currElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML DOM focus()ed then SVG focusTree()ed correctly updates getFocusedTree() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').focus(); + + this.focusManager.focusTree(this.testFocusableGroup2); + + const prevElem = this.testFocusableTree2Node1.getFocusableElement(); + const currElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML DOM focus()ed then SVG focusNode()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').focus(); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + const prevElem = this.testFocusableTree2Node1.getFocusableElement(); + const currElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('HTML DOM focus()ed then SVG DOM focus()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').focus(); + + document.getElementById('testFocusableGroup2.node1').focus(); + + const prevElem = this.testFocusableTree2Node1.getFocusableElement(); + const currElem = this.testFocusableGroup2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + suite('Focus SVG tree then HTML tree', function () { + test('SVG focusTree()ed then HTML focusTree()ed correctly updates getFocusedTree() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup2); + + this.focusManager.focusTree(this.testFocusableTree2); + + const prevElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + const currElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG focusTree()ed then HTML focusNode()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup2); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + const prevElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + const currElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG focusTree()ed then HTML DOM focus()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableGroup2); + + document.getElementById('testFocusableTree2.node1').focus(); + + const prevElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + const currElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG focusNode()ed then HTML focusTree()ed correctly updates getFocusedTree() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusTree(this.testFocusableTree2); + + const prevElem = this.testFocusableGroup2Node1.getFocusableElement(); + const currElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG focusNode()ed then HTML focusNode()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + const prevElem = this.testFocusableGroup2Node1.getFocusableElement(); + const currElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG focusNode()ed then HTML DOM focus()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + document.getElementById('testFocusableTree2.node1').focus(); + + const prevElem = this.testFocusableGroup2Node1.getFocusableElement(); + const currElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG DOM focus()ed then HTML focusTree()ed correctly updates getFocusedTree() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').focus(); + + this.focusManager.focusTree(this.testFocusableTree2); + + const prevElem = this.testFocusableGroup2Node1.getFocusableElement(); + const currElem = this.testFocusableTree2 + .getRootFocusableNode() + .getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableTree2, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG DOM focus()ed then HTML focusNode()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').focus(); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + const prevElem = this.testFocusableGroup2Node1.getFocusableElement(); + const currElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('SVG DOM focus()ed then HTML DOM focus()ed correctly updates getFocusedNode() and indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').focus(); + + document.getElementById('testFocusableTree2.node1').focus(); + + const prevElem = this.testFocusableGroup2Node1.getFocusableElement(); + const currElem = this.testFocusableTree2Node1.getFocusableElement(); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(document.activeElement, currElem); + assert.includesClass( + currElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + currElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.notIncludesClass( + prevElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.includesClass( + prevElem.classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + }); + }); + + /* Ephemeral focus tests. */ + + suite('takeEphemeralFocus()', function () { + test('with no focused node does not change states', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + // Taking focus without an existing node having focus should change no focus indicators. + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + assert.isEmpty(passiveElems); + }); + + test('with focused node changes focused node to passive', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + // Taking focus without an existing node having focus should change no focus indicators. + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + assert.strictEqual(passiveElems.length, 1); + assert.includesClass( + this.testFocusableTree2Node1.getFocusableElement().classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }); + + test('focuses provided HTML element', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + assert.strictEqual(document.activeElement, ephemeralElement); + }); + + test('focuses provided SVG element', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + assert.strictEqual(document.activeElement, ephemeralElement); + }); + + test('twice for without finishing previous throws error', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralGroupElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralDivElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralGroupElement); + + const errorMsgRegex = + /Attempted to take ephemeral focus when it's already held+?/; + assert.throws( + () => this.focusManager.takeEphemeralFocus(ephemeralDivElement), + errorMsgRegex, + ); + }); + + test('then focusTree() changes getFocusedTree() but not active state', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableTree2); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + this.focusManager.focusTree(this.testFocusableGroup2); + + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + }); + + test('then focusNode() changes getFocusedNode() but not active state', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + }); + + test('then DOM refocus changes getFocusedNode() but not active state', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement); + + // Force focus to change via the DOM. + document.getElementById('testFocusableGroup2.node1').focus(); + + // The focus() state change will affect getFocusedNode() but it will not cause the node to now + // be active. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + }); + + test('then finish ephemeral callback with no node does not change indicators', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + + finishFocusCallback(); + + // Finishing ephemeral focus without a previously focused node should not change indicators. + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + assert.isEmpty(passiveElems); + }); + + test('again after finishing previous empheral focus should focus new element', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralGroupElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralDivElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralGroupElement, + ); + + finishFocusCallback(); + this.focusManager.takeEphemeralFocus(ephemeralDivElement); + + // An exception should not be thrown and the new element should receive focus. + assert.strictEqual(document.activeElement, ephemeralDivElement); + }); + + test('calling ephemeral callback twice throws error', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + finishFocusCallback(); + + const errorMsgRegex = + /Attempted to finish ephemeral focus twice for element+?/; + assert.throws(() => finishFocusCallback(), errorMsgRegex); + }); + + test('then finish ephemeral callback should restore focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + + finishFocusCallback(); + + // The original focused node should be restored. + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('then focusTree() and finish ephemeral callback correctly sets new active state', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusTree(this.testFocusableTree2); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + + this.focusManager.focusTree(this.testFocusableGroup2); + finishFocusCallback(); + + // The tree's root should now be the active element since focus changed between the start and + // end of the ephemeral flow. + const rootElem = this.testFocusableGroup2 + .getRootFocusableNode() + .getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedTree(), + this.testFocusableGroup2, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + rootElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, rootElem); + }); + + test('then focusNode() and finish ephemeral callback correctly sets new active state', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + + this.focusManager.focusNode(this.testFocusableGroup2Node1); + finishFocusCallback(); + + // The tree's root should now be the active element since focus changed between the start and + // end of the ephemeral flow. + const nodeElem = this.testFocusableGroup2Node1.getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('then DOM focus change and finish ephemeral callback correctly sets new active state', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + + document.getElementById('testFocusableGroup2.node1').focus(); + finishFocusCallback(); + + // The tree's root should now be the active element since focus changed between the start and + // end of the ephemeral flow. + const nodeElem = this.testFocusableGroup2Node1.getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup2Node1, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, nodeElem); + }); + }); +}); diff --git a/tests/mocha/focusable_tree_traverser_test.js b/tests/mocha/focusable_tree_traverser_test.js new file mode 100644 index 00000000000..66cc598ccf5 --- /dev/null +++ b/tests/mocha/focusable_tree_traverser_test.js @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FocusManager} from '../../build/src/core/focus_manager.js'; +import {FocusableTreeTraverser} from '../../build/src/core/utils/focusable_tree_traverser.js'; +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +class FocusableNodeImpl { + constructor(element, tree) { + this.element = element; + this.tree = tree; + } + + getFocusableElement() { + return this.element; + } + + getFocusableTree() { + return this.tree; + } + + onNodeFocus() {} + + onNodeBlur() {} + + canBeFocused() { + return true; + } +} + +class FocusableTreeImpl { + constructor(rootElement, nestedTrees) { + this.nestedTrees = nestedTrees; + this.idToNodeMap = {}; + this.rootNode = this.addNode(rootElement); + } + + addNode(element) { + const node = new FocusableNodeImpl(element, this); + this.idToNodeMap[element.id] = node; + return node; + } + + getRootFocusableNode() { + return this.rootNode; + } + + getRestoredFocusableNode() { + return null; + } + + getNestedTrees() { + return this.nestedTrees; + } + + lookUpFocusableNode(id) { + return this.idToNodeMap[id]; + } + + onTreeFocus() {} + + onTreeBlur() {} +} + +suite('FocusableTreeTraverser', function () { + setup(function () { + sharedTestSetup.call(this); + + const createFocusableTree = function (rootElementId, nestedTrees) { + return new FocusableTreeImpl( + document.getElementById(rootElementId), + nestedTrees || [], + ); + }; + const createFocusableNode = function (tree, elementId) { + return tree.addNode(document.getElementById(elementId)); + }; + + this.testFocusableTree1 = createFocusableTree('testFocusableTree1'); + this.testFocusableTree1Node1 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node1', + ); + this.testFocusableTree1Node1Child1 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node1.child1', + ); + this.testFocusableTree1Node2 = createFocusableNode( + this.testFocusableTree1, + 'testFocusableTree1.node2', + ); + this.testFocusableNestedTree4 = createFocusableTree( + 'testFocusableNestedTree4', + ); + this.testFocusableNestedTree4Node1 = createFocusableNode( + this.testFocusableNestedTree4, + 'testFocusableNestedTree4.node1', + ); + this.testFocusableNestedTree5 = createFocusableTree( + 'testFocusableNestedTree5', + ); + this.testFocusableNestedTree5Node1 = createFocusableNode( + this.testFocusableNestedTree5, + 'testFocusableNestedTree5.node1', + ); + this.testFocusableTree2 = createFocusableTree('testFocusableTree2', [ + this.testFocusableNestedTree4, + this.testFocusableNestedTree5, + ]); + this.testFocusableTree2Node1 = createFocusableNode( + this.testFocusableTree2, + 'testFocusableTree2.node1', + ); + }); + + teardown(function () { + sharedTestTeardown.call(this); + + const removeFocusIndicators = function (element) { + element.classList.remove( + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + }; + + // Ensure all node CSS styles are reset so that state isn't leaked between tests. + removeFocusIndicators(document.getElementById('testFocusableTree1')); + removeFocusIndicators(document.getElementById('testFocusableTree1.node1')); + removeFocusIndicators( + document.getElementById('testFocusableTree1.node1.child1'), + ); + removeFocusIndicators(document.getElementById('testFocusableTree1.node2')); + removeFocusIndicators(document.getElementById('testFocusableTree2')); + removeFocusIndicators(document.getElementById('testFocusableTree2.node1')); + removeFocusIndicators(document.getElementById('testFocusableNestedTree4')); + removeFocusIndicators( + document.getElementById('testFocusableNestedTree4.node1'), + ); + removeFocusIndicators(document.getElementById('testFocusableNestedTree5')); + removeFocusIndicators( + document.getElementById('testFocusableNestedTree5.node1'), + ); + }); + + suite('findFocusedNode()', function () { + test('for tree with no highlights returns null', function () { + const tree = this.testFocusableTree1; + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.isNull(finding); + }); + + test('for tree with root active highlight returns root node', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + rootNode + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, rootNode); + }); + + test('for tree with root passive highlight returns root node', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + rootNode + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, rootNode); + }); + + test('for tree with node active highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1; + node + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, node); + }); + + test('for tree with node passive highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1; + node + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, node); + }); + + test('for tree with nested node active highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1Child1; + node + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, node); + }); + + test('for tree with nested node passive highlight returns node', function () { + const tree = this.testFocusableTree1; + const node = this.testFocusableTree1Node1Child1; + node + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, node); + }); + + test('for tree with nested tree root active no parent highlights returns root', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + rootNode + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, rootNode); + }); + + test('for tree with nested tree root passive no parent highlights returns root', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + rootNode + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, rootNode); + }); + + test('for tree with nested tree node active no parent highlights returns node', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + node + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, node); + }); + + test('for tree with nested tree root passive no parent highlights returns null', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + node + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode(tree); + + assert.strictEqual(finding, node); + }); + + test('for tree with nested tree root active parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + rootNode + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.strictEqual(finding, this.testFocusableTree2Node1); + }); + + test('for tree with nested tree root passive parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = this.testFocusableNestedTree4.getRootFocusableNode(); + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + rootNode + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.strictEqual(finding, this.testFocusableTree2Node1); + }); + + test('for tree with nested tree node active parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + node + .getFocusableElement() + .classList.add(FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.strictEqual(finding, this.testFocusableTree2Node1); + }); + + test('for tree with nested tree node passive parent node passive returns parent node', function () { + const tree = this.testFocusableNestedTree4; + const node = this.testFocusableNestedTree4Node1; + this.testFocusableTree2Node1 + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + node + .getFocusableElement() + .classList.add(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); + + const finding = FocusableTreeTraverser.findFocusedNode( + this.testFocusableTree2, + ); + + assert.strictEqual(finding, this.testFocusableTree2Node1); + }); + }); + + suite('findFocusableNodeFor()', function () { + test('for root element returns root', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + + assert.strictEqual(finding, rootNode); + }); + + test('for element for different tree root returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = this.testFocusableTree2.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + + assert.isNull(finding); + }); + + test('for element for different tree node returns null', function () { + const tree = this.testFocusableTree1; + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + assert.isNull(finding); + }); + + test('for node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const nodeElem = this.testFocusableTree1Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + assert.strictEqual(finding, this.testFocusableTree1Node1); + }); + + test('for non-node element in tree returns root', function () { + const tree = this.testFocusableTree1; + const unregElem = document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node. + assert.strictEqual(finding, this.testFocusableTree1Node2); + }); + + test('for nested node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const nodeElem = this.testFocusableTree1Node1Child1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The nested node should be returned. + assert.strictEqual(finding, this.testFocusableTree1Node1Child1); + }); + + test('for nested node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const unregElem = document.getElementById( + 'testFocusableTree1.node1.child1.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node. + assert.strictEqual(finding, this.testFocusableTree1Node1Child1); + }); + + test('for nested node element in tree returns node', function () { + const tree = this.testFocusableTree1; + const unregElem = document.getElementById( + 'testFocusableTree1.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node (or root). + assert.strictEqual(finding, tree.getRootFocusableNode()); + }); + + test('for nested tree root returns nested tree root', function () { + const tree = this.testFocusableNestedTree4; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + + assert.strictEqual(finding, rootNode); + }); + + test('for nested tree node returns nested tree node', function () { + const tree = this.testFocusableNestedTree4; + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The node of the nested tree should be returned. + assert.strictEqual(finding, this.testFocusableNestedTree4Node1); + }); + + test('for nested element in nested tree node returns nearest nested node', function () { + const tree = this.testFocusableNestedTree4; + const unregElem = document.getElementById( + 'testFocusableNestedTree4.node1.unregisteredChild1', + ); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + unregElem, + tree, + ); + + // An unregistered element should map to the closest node. + assert.strictEqual(finding, this.testFocusableNestedTree4Node1); + }); + + test('for nested tree node under root with different tree base returns null', function () { + const tree = this.testFocusableTree2; + const nodeElem = this.testFocusableNestedTree5Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The nested node hierarchically sits below the outer tree, but using + // that tree as the basis should yield null since it's not a direct child. + assert.isNull(finding); + }); + + test('for nested tree node under node with different tree base returns null', function () { + const tree = this.testFocusableTree2; + const nodeElem = this.testFocusableNestedTree4Node1.getFocusableElement(); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + nodeElem, + tree, + ); + + // The nested node hierarchically sits below the outer tree, but using + // that tree as the basis should yield null since it's not a direct child. + assert.isNull(finding); + }); + }); +}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index adc63da4a12..09ef8820f0e 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -13,11 +13,152 @@ visibility: hidden; width: 1000px; } + + .blocklyActiveFocus { + outline-color: #0f0; + outline-width: 2px; + } + .blocklyPassiveFocus { + outline-color: #00f; + outline-width: 1.5px; + } + div.blocklyActiveFocus { + color: #0f0; + } + div.blocklyPassiveFocus { + color: #00f; + } + g.blocklyActiveFocus { + fill: #0f0; + } + g.blocklyPassiveFocus { + fill: #00f; + } - +
+
+ Focusable tree 1 +
+ Tree 1 node 1 +
+ Tree 1 node 1 child 1 +
+ Tree 1 node 1 child 1 child 1 (unregistered) +
+
+
+
+ Tree 1 node 2 +
+ Tree 1 node 2 child 2 (unregistered) +
+
+
+ Tree 1 child 1 (unregistered) +
+
+
+ Focusable tree 2 +
+ Tree 2 node 1 +
+ Nested tree 4 +
+ Tree 4 node 1 (nested) +
+ Tree 4 node 1 child 1 (unregistered) +
+
+
+
+
+ Nested tree 5 +
+ Tree 5 node 1 (nested) +
+
+
+
+ Unregistered tree 3 +
+ Tree 3 node 1 (unregistered) +
+
+
Unfocusable element
+
+ + + + + Group 1 node 1 + + + Tree 1 node 1 child 1 + + + + + Group 1 node 2 + + + + Tree 1 node 2 child 2 (unregistered) + + + + + + + + Group 2 node 1 + + + + + Group 4 node 1 (nested) + + + + + + + + Tree 3 node 1 (unregistered) + + + + + @@ -39,7 +180,6 @@ import {javascriptGenerator} from '../../build/javascript.loader.mjs'; // Import tests. - import './astnode_test.js'; import './block_json_test.js'; import './block_test.js'; import './clipboard_test.js'; @@ -51,6 +191,7 @@ import './contextmenu_items_test.js'; import './contextmenu_test.js'; import './cursor_test.js'; + import './dialog_test.js'; import './dropdowndiv_test.js'; import './event_test.js'; import './event_block_change_test.js'; @@ -68,7 +209,6 @@ import './event_comment_move_test.js'; import './event_comment_drag_test.js'; import './event_comment_resize_test.js'; - import './event_marker_move_test.js'; import './event_selected_test.js'; import './event_theme_change_test.js'; import './event_toolbox_item_select_test.js'; @@ -76,6 +216,7 @@ import './event_var_create_test.js'; import './event_var_delete_test.js'; import './event_var_rename_test.js'; + import './event_var_type_change_test.js'; import './event_viewport_test.js'; import './extensions_test.js'; import './field_checkbox_test.js'; @@ -89,16 +230,16 @@ import './field_textinput_test.js'; import './field_variable_test.js'; import './flyout_test.js'; + import './focus_manager_test.js'; + import './focusable_tree_traverser_test.js'; import './generator_test.js'; import './gesture_test.js'; import './icon_test.js'; import './input_test.js'; import './insertion_marker_test.js'; - import './insertion_marker_manager_test.js'; import './jso_deserialization_test.js'; import './jso_serialization_test.js'; import './json_test.js'; - import './keydown_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; import './blocks/logic_ternary_test.js'; @@ -106,6 +247,7 @@ import './metrics_test.js'; import './mutator_test.js'; import './names_test.js'; + import './navigation_test.js'; // TODO: Remove these tests. import './old_workspace_comment_test.js'; import './procedure_map_test.js'; @@ -114,9 +256,11 @@ import './registry_test.js'; import './render_management_test.js'; import './serializer_test.js'; + import './shortcut_items_test.js'; import './shortcut_registry_test.js'; import './touch_test.js'; import './theme_test.js'; + import './toast_test.js'; import './toolbox_test.js'; import './tooltip_test.js'; import './trashcan_test.js'; diff --git a/tests/mocha/insertion_marker_manager_test.js b/tests/mocha/insertion_marker_manager_test.js deleted file mode 100644 index 3fae888df38..00000000000 --- a/tests/mocha/insertion_marker_manager_test.js +++ /dev/null @@ -1,443 +0,0 @@ -/** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {assert} from '../../node_modules/chai/chai.js'; -import { - defineRowBlock, - defineRowToStackBlock, - defineStackBlock, -} from './test_helpers/block_definitions.js'; -import { - sharedTestSetup, - sharedTestTeardown, -} from './test_helpers/setup_teardown.js'; - -suite('Insertion marker manager', function () { - setup(function () { - sharedTestSetup.call(this); - defineRowBlock(); - defineStackBlock(); - defineRowToStackBlock(); - this.workspace = Blockly.inject('blocklyDiv'); - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - - suite('Creating markers', function () { - function createBlocksAndManager(workspace, state) { - Blockly.serialization.workspaces.load(state, workspace); - const block = workspace.getBlockById('first'); - const manager = new Blockly.InsertionMarkerManager(block); - return manager; - } - - test('One stack block creates one marker', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'first', - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 1); - }); - - test('Two stack blocks create two markers', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'first', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'second', - }, - }, - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 2); - }); - - test('Three stack blocks create two markers', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'first', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'second', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'third', - }, - }, - }, - }, - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 2); - }); - - test('One value block creates one marker', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'row_block', - 'id': 'first', - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 1); - }); - - test('Two value blocks create one marker', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'row_block', - 'id': 'first', - 'inputs': { - 'INPUT': { - 'block': { - 'type': 'row_block', - 'id': 'second', - }, - }, - }, - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 1); - }); - - test('One row to stack block creates one marker', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'row_to_stack_block', - 'id': 'first', - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 1); - }); - - test('Row to stack block with child creates two markers', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'row_to_stack_block', - 'id': 'first', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'second', - }, - }, - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.equal(markers.length, 2); - }); - - suite('children being set as insertion markers', function () { - setup(function () { - Blockly.Blocks['shadows_in_init'] = { - init: function () { - this.appendValueInput('test').connection.setShadowState({ - 'type': 'math_number', - }); - this.setPreviousStatement(true); - }, - }; - - Blockly.Blocks['shadows_in_load'] = { - init: function () { - this.appendValueInput('test'); - this.setPreviousStatement(true); - }, - - loadExtraState: function () { - this.getInput('test').connection.setShadowState({ - 'type': 'math_number', - }); - }, - - saveExtraState: function () { - return true; - }, - }; - }); - - teardown(function () { - delete Blockly.Blocks['shadows_in_init']; - delete Blockly.Blocks['shadows_in_load']; - }); - - test('Shadows added in init are set as insertion markers', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'id': 'first', - 'type': 'shadows_in_init', - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.isTrue( - markers[0].getChildren()[0].isInsertionMarker(), - 'Expected the shadow block to be an insertion maker', - ); - }); - - test('Shadows added in `loadExtraState` are set as insertion markers', function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'id': 'first', - 'type': 'shadows_in_load', - }, - ], - }, - }; - const manager = createBlocksAndManager(this.workspace, state); - const markers = manager.getInsertionMarkers(); - assert.isTrue( - markers[0].getChildren()[0].isInsertionMarker(), - 'Expected the shadow block to be an insertion maker', - ); - }); - }); - }); - - suite('Would delete block', function () { - setup(function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'first', - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.block = this.workspace.getBlockById('first'); - this.manager = new Blockly.InsertionMarkerManager(this.block); - - const componentManager = this.workspace.getComponentManager(); - this.stub = sinon.stub(componentManager, 'hasCapability'); - this.dxy = new Blockly.utils.Coordinate(0, 0); - }); - - test('Over delete area and accepted would delete', function () { - this.stub - .withArgs( - 'fakeDragTarget', - Blockly.ComponentManager.Capability.DELETE_AREA, - ) - .returns(true); - const fakeDragTarget = { - wouldDelete: sinon.fake.returns(true), - id: 'fakeDragTarget', - }; - this.manager.update(this.dxy, fakeDragTarget); - assert.isTrue(this.manager.wouldDeleteBlock); - }); - - test('Over delete area and rejected would not delete', function () { - this.stub - .withArgs( - 'fakeDragTarget', - Blockly.ComponentManager.Capability.DELETE_AREA, - ) - .returns(true); - const fakeDragTarget = { - wouldDelete: sinon.fake.returns(false), - id: 'fakeDragTarget', - }; - this.manager.update(this.dxy, fakeDragTarget); - assert.isFalse(this.manager.wouldDeleteBlock); - }); - - test('Drag target is not a delete area would not delete', function () { - this.stub - .withArgs( - 'fakeDragTarget', - Blockly.ComponentManager.Capability.DELETE_AREA, - ) - .returns(false); - const fakeDragTarget = { - wouldDelete: sinon.fake.returns(false), - id: 'fakeDragTarget', - }; - this.manager.update(this.dxy, fakeDragTarget); - assert.isFalse(this.manager.wouldDeleteBlock); - }); - - test('Not over drag target would not delete', function () { - this.manager.update(this.dxy, null); - assert.isFalse(this.manager.wouldDeleteBlock); - }); - }); - - suite('Would connect stack blocks', function () { - setup(function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'first', - 'x': 0, - 'y': 0, - }, - { - 'type': 'stack_block', - 'id': 'other', - 'x': 200, - 'y': 200, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.block = this.workspace.getBlockById('first'); - this.block.setDragging(true); - this.manager = new Blockly.InsertionMarkerManager(this.block); - }); - - test('No other blocks nearby would not connect', function () { - this.manager.update(new Blockly.utils.Coordinate(0, 0), null); - assert.isFalse(this.manager.wouldConnectBlock()); - }); - - test('Near other block and above would connect before', function () { - this.manager.update(new Blockly.utils.Coordinate(200, 190), null); - assert.isTrue(this.manager.wouldConnectBlock()); - const markers = this.manager.getInsertionMarkers(); - assert.equal(markers.length, 1); - const marker = markers[0]; - assert.isTrue(marker.nextConnection.isConnected()); - }); - - test('Near other block and below would connect after', function () { - this.manager.update(new Blockly.utils.Coordinate(200, 210), null); - assert.isTrue(this.manager.wouldConnectBlock()); - const markers = this.manager.getInsertionMarkers(); - assert.equal(markers.length, 1); - const marker = markers[0]; - assert.isTrue(marker.previousConnection.isConnected()); - }); - - test('Near other block and left would connect', function () { - this.manager.update(new Blockly.utils.Coordinate(190, 200), null); - assert.isTrue(this.manager.wouldConnectBlock()); - }); - - test('Near other block and right would connect', function () { - this.manager.update(new Blockly.utils.Coordinate(210, 200), null); - assert.isTrue(this.manager.wouldConnectBlock()); - }); - }); - - suite('Would connect row blocks', function () { - setup(function () { - const state = { - 'blocks': { - 'blocks': [ - { - 'type': 'row_block', - 'id': 'first', - 'x': 0, - 'y': 0, - }, - { - 'type': 'row_block', - 'id': 'other', - 'x': 200, - 'y': 200, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.block = this.workspace.getBlockById('first'); - this.block.setDragging(true); - this.manager = new Blockly.InsertionMarkerManager(this.block); - }); - - test('No other blocks nearby would not connect', function () { - this.manager.update(new Blockly.utils.Coordinate(0, 0), null); - assert.isFalse(this.manager.wouldConnectBlock()); - }); - - test('Near other block and above would connect', function () { - this.manager.update(new Blockly.utils.Coordinate(200, 190), null); - assert.isTrue(this.manager.wouldConnectBlock()); - }); - - test('Near other block and below would connect', function () { - this.manager.update(new Blockly.utils.Coordinate(200, 210), null); - assert.isTrue(this.manager.wouldConnectBlock()); - }); - - test('Near other block and left would connect before', function () { - this.manager.update(new Blockly.utils.Coordinate(190, 200), null); - assert.isTrue(this.manager.wouldConnectBlock()); - const markers = this.manager.getInsertionMarkers(); - assert.isTrue(markers[0].getInput('INPUT').connection.isConnected()); - }); - - test('Near other block and right would connect after', function () { - this.manager.update(new Blockly.utils.Coordinate(210, 200), null); - assert.isTrue(this.manager.wouldConnectBlock()); - const markers = this.manager.getInsertionMarkers(); - assert.isTrue(markers[0].outputConnection.isConnected()); - }); - }); -}); diff --git a/tests/mocha/json_test.js b/tests/mocha/json_test.js index e9e465c65bc..471d2fb9711 100644 --- a/tests/mocha/json_test.js +++ b/tests/mocha/json_test.js @@ -256,12 +256,6 @@ suite('JSON Block Definitions', function () { 'alt': '%{BKY_ALT_TEXT}', }; const VALUE1 = 'VALUE1'; - const IMAGE2 = { - 'width': 90, - 'height': 123, - 'src': 'http://image2.src', - }; - const VALUE2 = 'VALUE2'; Blockly.defineBlocksWithJsonArray([ { @@ -274,7 +268,6 @@ suite('JSON Block Definitions', function () { 'options': [ [IMAGE0, VALUE0], [IMAGE1, VALUE1], - [IMAGE2, VALUE2], ], }, ], @@ -305,11 +298,6 @@ suite('JSON Block Definitions', function () { assertImageEquals(IMAGE1, image1); assert.equal(image1.alt, IMAGE1_ALT_TEXT); // Via Msg reference assert.equal(VALUE1, options[1][1]); - - const image2 = options[2][0]; - assertImageEquals(IMAGE1, image1); - assert.notExists(image2.alt); // No alt specified. - assert.equal(VALUE2, options[2][1]); }); }); }); diff --git a/tests/mocha/layering_test.js b/tests/mocha/layering_test.js index efc3ef3d632..1ef0ee6973d 100644 --- a/tests/mocha/layering_test.js +++ b/tests/mocha/layering_test.js @@ -24,6 +24,15 @@ suite('Layering', function () { const g = Blockly.utils.dom.createSvgElement('g', {}); return { getSvgRoot: () => g, + getFocusableElement: () => { + throw new Error('Unsupported.'); + }, + getFocusableTree: () => { + throw new Error('Unsupported.'); + }, + onNodeFocus: () => {}, + onNodeBlur: () => {}, + canBeFocused: () => false, }; } diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js new file mode 100644 index 00000000000..0462d4daa0d --- /dev/null +++ b/tests/mocha/navigation_test.js @@ -0,0 +1,570 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, + workspaceTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Navigation', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'input_statement', + 'message0': '%1 %2 %3 %4', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'NAME', + }, + { + 'type': 'input_statement', + 'name': 'NAME', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'value_input', + 'message0': '%1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'NAME', + }, + ], + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'field_input', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'output': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.navigator = this.workspace.getNavigator(); + const statementInput1 = this.workspace.newBlock('input_statement'); + const statementInput2 = this.workspace.newBlock('input_statement'); + const statementInput3 = this.workspace.newBlock('input_statement'); + const statementInput4 = this.workspace.newBlock('input_statement'); + const fieldWithOutput = this.workspace.newBlock('field_input'); + const valueInput = this.workspace.newBlock('value_input'); + + statementInput1.nextConnection.connect(statementInput2.previousConnection); + statementInput1.inputList[0].connection.connect( + fieldWithOutput.outputConnection, + ); + statementInput2.inputList[1].connection.connect( + statementInput3.previousConnection, + ); + + this.blocks = { + statementInput1: statementInput1, + statementInput2: statementInput2, + statementInput3: statementInput3, + statementInput4: statementInput4, + fieldWithOutput: fieldWithOutput, + valueInput: valueInput, + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + + suite('NavigationFunctions', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'top_connection', + 'message0': '', + 'previousStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'start_block', + 'message0': '', + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'fields_and_input', + 'message0': '%1 hi %2 %3 %4', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + { + 'type': 'input_dummy', + }, + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'NAME', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'two_fields', + 'message0': '%1 hi', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'fields_and_input2', + 'message0': '%1 %2 %3 %4 bye', + 'args0': [ + { + 'type': 'input_value', + 'name': 'NAME', + }, + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'NAME', + }, + { + 'type': 'input_statement', + 'name': 'NAME', + }, + ], + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'dummy_input', + 'message0': 'Hello', + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'dummy_inputValue', + 'message0': 'Hello %1 %2', + 'args0': [ + { + 'type': 'input_dummy', + }, + { + 'type': 'input_value', + 'name': 'NAME', + }, + ], + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, + { + 'type': 'output_next', + 'message0': '', + 'output': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + 'nextStatement': null, + }, + ]); + const noNextConnection = this.workspace.newBlock('top_connection'); + const fieldAndInputs = this.workspace.newBlock('fields_and_input'); + const twoFields = this.workspace.newBlock('two_fields'); + const fieldAndInputs2 = this.workspace.newBlock('fields_and_input2'); + const noPrevConnection = this.workspace.newBlock('start_block'); + this.blocks.noNextConnection = noNextConnection; + this.blocks.fieldAndInputs = fieldAndInputs; + this.blocks.twoFields = twoFields; + this.blocks.fieldAndInputs2 = fieldAndInputs2; + this.blocks.noPrevConnection = noPrevConnection; + + const dummyInput = this.workspace.newBlock('dummy_input'); + const dummyInputValue = this.workspace.newBlock('dummy_inputValue'); + const fieldWithOutput2 = this.workspace.newBlock('field_input'); + this.blocks.dummyInput = dummyInput; + this.blocks.dummyInputValue = dummyInputValue; + this.blocks.fieldWithOutput2 = fieldWithOutput2; + + const secondBlock = this.workspace.newBlock('input_statement'); + const outputNextBlock = this.workspace.newBlock('output_next'); + this.blocks.secondBlock = secondBlock; + this.blocks.outputNextBlock = outputNextBlock; + this.workspace.cleanUp(); + }); + suite('Next', function () { + setup(function () { + this.singleBlockWorkspace = new Blockly.Workspace(); + const singleBlock = this.singleBlockWorkspace.newBlock('two_fields'); + this.blocks.singleBlock = singleBlock; + }); + teardown(function () { + workspaceTeardown.call(this, this.singleBlockWorkspace); + }); + + test('fromPreviousToBlock', function () { + const prevConnection = this.blocks.statementInput1.previousConnection; + const nextNode = this.navigator.getNextSibling(prevConnection); + assert.equal(nextNode, this.blocks.statementInput1); + }); + test('fromBlockToNextBlock', function () { + const nextNode = this.navigator.getNextSibling( + this.blocks.statementInput1, + ); + assert.equal(nextNode, this.blocks.statementInput2); + }); + test('fromNextToPrevious', function () { + const nextConnection = this.blocks.statementInput1.nextConnection; + const prevConnection = this.blocks.statementInput2.previousConnection; + const nextNode = this.navigator.getNextSibling(nextConnection); + assert.equal(nextNode, prevConnection); + }); + test('fromInputToInput', function () { + const input = this.blocks.statementInput1.inputList[0]; + const inputConnection = + this.blocks.statementInput1.inputList[1].connection; + const nextNode = this.navigator.getNextSibling(input.connection); + assert.equal(nextNode, inputConnection); + }); + test('fromInputToStatementInput', function () { + const input = this.blocks.fieldAndInputs2.inputList[1]; + const inputConnection = + this.blocks.fieldAndInputs2.inputList[2].connection; + const nextNode = this.navigator.getNextSibling(input.connection); + assert.equal(nextNode, inputConnection); + }); + test('fromInputToField', function () { + const input = this.blocks.fieldAndInputs2.inputList[0]; + const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; + const nextNode = this.navigator.getNextSibling(input.connection); + assert.equal(nextNode, field); + }); + test('fromInputToNull', function () { + const input = this.blocks.fieldAndInputs2.inputList[2]; + const nextNode = this.navigator.getNextSibling(input.connection); + assert.isNull(nextNode); + }); + test('fromOutputToBlock', function () { + const output = this.blocks.fieldWithOutput.outputConnection; + const nextNode = this.navigator.getNextSibling(output); + assert.equal(nextNode, this.blocks.fieldWithOutput); + }); + test('fromFieldToNestedBlock', function () { + const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; + const inputConnection = + this.blocks.statementInput1.inputList[0].connection; + const nextNode = this.navigator.getNextSibling(field); + assert.equal(nextNode, this.blocks.fieldWithOutput); + }); + test('fromFieldToField', function () { + const field = this.blocks.fieldAndInputs.inputList[0].fieldRow[0]; + const field2 = this.blocks.fieldAndInputs.inputList[1].fieldRow[0]; + const nextNode = this.navigator.getNextSibling(field); + assert.equal(nextNode, field2); + }); + test('fromFieldToNull', function () { + const field = this.blocks.twoFields.inputList[0].fieldRow[0]; + const nextNode = this.navigator.getNextSibling(field); + assert.isNull(nextNode); + }); + }); + + suite('Previous', function () { + test('fromPreviousToNext', function () { + const prevConnection = this.blocks.statementInput2.previousConnection; + const prevNode = this.navigator.getPreviousSibling(prevConnection); + const nextConnection = this.blocks.statementInput1.nextConnection; + assert.equal(prevNode, nextConnection); + }); + test('fromPreviousToInput', function () { + const prevConnection = this.blocks.statementInput3.previousConnection; + const prevNode = this.navigator.getPreviousSibling(prevConnection); + assert.isNull(prevNode); + }); + test('fromBlockToPrevious', function () { + const prevNode = this.navigator.getPreviousSibling( + this.blocks.statementInput2, + ); + const previousBlock = this.blocks.statementInput1; + assert.equal(prevNode, previousBlock); + }); + test('fromOutputBlockToPreviousField', function () { + const prevNode = this.navigator.getPreviousSibling( + this.blocks.fieldWithOutput, + ); + const outputConnection = this.blocks.fieldWithOutput.outputConnection; + assert.equal(prevNode, [...this.blocks.statementInput1.getFields()][1]); + }); + test('fromNextToBlock', function () { + const nextConnection = this.blocks.statementInput1.nextConnection; + const prevNode = this.navigator.getPreviousSibling(nextConnection); + assert.equal(prevNode, this.blocks.statementInput1); + }); + test('fromInputToField', function () { + const input = this.blocks.statementInput1.inputList[0]; + const prevNode = this.navigator.getPreviousSibling(input.connection); + assert.equal(prevNode, input.fieldRow[1]); + }); + test('fromInputToNull', function () { + const input = this.blocks.fieldAndInputs2.inputList[0]; + const prevNode = this.navigator.getPreviousSibling(input.connection); + assert.isNull(prevNode); + }); + test('fromInputToInput', function () { + const input = this.blocks.fieldAndInputs2.inputList[2]; + const inputConnection = + this.blocks.fieldAndInputs2.inputList[1].connection; + const prevNode = this.navigator.getPreviousSibling(input.connection); + assert.equal(prevNode, inputConnection); + }); + test('fromOutputToNull', function () { + const output = this.blocks.fieldWithOutput.outputConnection; + const prevNode = this.navigator.getPreviousSibling(output); + assert.isNull(prevNode); + }); + test('fromFieldToNull', function () { + const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; + const prevNode = this.navigator.getPreviousSibling(field); + assert.isNull(prevNode); + }); + test('fromFieldToInput', function () { + const outputBlock = this.workspace.newBlock('field_input'); + this.blocks.fieldAndInputs2.inputList[0].connection.connect( + outputBlock.outputConnection, + ); + + const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; + const inputConnection = + this.blocks.fieldAndInputs2.inputList[0].connection; + const prevNode = this.navigator.getPreviousSibling(field); + assert.equal(prevNode, outputBlock); + }); + test('fromFieldToField', function () { + const field = this.blocks.fieldAndInputs.inputList[1].fieldRow[0]; + const field2 = this.blocks.fieldAndInputs.inputList[0].fieldRow[0]; + const prevNode = this.navigator.getPreviousSibling(field); + assert.equal(prevNode, field2); + }); + }); + + suite('In', function () { + setup(function () { + this.emptyWorkspace = Blockly.inject(document.createElement('div'), {}); + }); + teardown(function () { + workspaceTeardown.call(this, this.emptyWorkspace); + }); + + test('fromInputToOutput', function () { + const input = this.blocks.statementInput1.inputList[0]; + const inNode = this.navigator.getFirstChild(input.connection); + const outputConnection = this.blocks.fieldWithOutput.outputConnection; + assert.equal(inNode, outputConnection); + }); + test('fromInputToNull', function () { + const input = this.blocks.statementInput2.inputList[0]; + const inNode = this.navigator.getFirstChild(input.connection); + assert.isNull(inNode); + }); + test('fromInputToPrevious', function () { + const input = this.blocks.statementInput2.inputList[1]; + const previousConnection = + this.blocks.statementInput3.previousConnection; + const inNode = this.navigator.getFirstChild(input.connection); + assert.equal(inNode, previousConnection); + }); + test('fromBlockToField', function () { + const field = this.blocks.valueInput.getField('NAME'); + const inNode = this.navigator.getFirstChild(this.blocks.valueInput); + assert.equal(inNode, field); + }); + test('fromBlockToField', function () { + const inNode = this.navigator.getFirstChild( + this.blocks.statementInput1, + ); + const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; + assert.equal(inNode, field); + }); + test('fromBlockToNull_DummyInput', function () { + const inNode = this.navigator.getFirstChild(this.blocks.dummyInput); + assert.isNull(inNode); + }); + test('fromBlockToInput_DummyInputValue', function () { + const inNode = this.navigator.getFirstChild( + this.blocks.dummyInputValue, + ); + assert.equal(inNode, null); + }); + test('fromOuputToNull', function () { + const output = this.blocks.fieldWithOutput.outputConnection; + const inNode = this.navigator.getFirstChild(output); + assert.isNull(inNode); + }); + test('fromFieldToNull', function () { + const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; + const inNode = this.navigator.getFirstChild(field); + assert.isNull(inNode); + }); + test('fromWorkspaceToBlock', function () { + const inNode = this.navigator.getFirstChild(this.workspace); + assert.equal(inNode, this.workspace.getTopBlocks(true)[0]); + }); + test('fromWorkspaceToNull', function () { + const inNode = this.navigator.getFirstChild(this.emptyWorkspace); + assert.isNull(inNode); + }); + }); + + suite('Out', function () { + setup(function () { + const secondBlock = this.blocks.secondBlock; + const outputNextBlock = this.blocks.outputNextBlock; + this.blocks.noPrevConnection.nextConnection.connect( + secondBlock.previousConnection, + ); + secondBlock.inputList[0].connection.connect( + outputNextBlock.outputConnection, + ); + }); + + test('fromInputToBlock', function () { + const input = this.blocks.statementInput1.inputList[0]; + const outNode = this.navigator.getParent(input.connection); + assert.equal(outNode, this.blocks.statementInput1); + }); + test('fromOutputToInput', function () { + const output = this.blocks.fieldWithOutput.outputConnection; + const outNode = this.navigator.getParent(output); + assert.equal( + outNode, + this.blocks.statementInput1.inputList[0].connection, + ); + }); + test('fromOutputToBlock', function () { + const output = this.blocks.fieldWithOutput2.outputConnection; + const outNode = this.navigator.getParent(output); + assert.equal(outNode, this.blocks.fieldWithOutput2); + }); + test('fromFieldToBlock', function () { + const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; + const outNode = this.navigator.getParent(field); + assert.equal(outNode, this.blocks.statementInput1); + }); + test('fromPreviousToInput', function () { + const previous = this.blocks.statementInput3.previousConnection; + const inputConnection = + this.blocks.statementInput2.inputList[1].connection; + const outNode = this.navigator.getParent(previous); + assert.equal(outNode, inputConnection); + }); + test('fromPreviousToBlock', function () { + const previous = this.blocks.statementInput2.previousConnection; + const outNode = this.navigator.getParent(previous); + assert.equal(outNode, this.blocks.statementInput1); + }); + test('fromNextToInput', function () { + const next = this.blocks.statementInput3.nextConnection; + const inputConnection = + this.blocks.statementInput2.inputList[1].connection; + const outNode = this.navigator.getParent(next); + assert.equal(outNode, inputConnection); + }); + test('fromNextToBlock', function () { + const next = this.blocks.statementInput2.nextConnection; + const outNode = this.navigator.getParent(next); + assert.equal(outNode, this.blocks.statementInput1); + }); + test('fromNextToBlock_NoPreviousConnection', function () { + const next = this.blocks.secondBlock.nextConnection; + const outNode = this.navigator.getParent(next); + assert.equal(outNode, this.blocks.noPrevConnection); + }); + /** + * This is where there is a block with both an output connection and a + * next connection attached to an input. + */ + test('fromNextToInput_OutputAndPreviousConnection', function () { + const next = this.blocks.outputNextBlock.nextConnection; + const outNode = this.navigator.getParent(next); + assert.equal(outNode, this.blocks.secondBlock.inputList[0].connection); + }); + test('fromBlockToWorkspace', function () { + const outNode = this.navigator.getParent(this.blocks.statementInput2); + assert.equal(outNode, this.workspace); + }); + test('fromBlockToEnclosingStatement', function () { + const enclosingStatement = this.blocks.statementInput2; + const outNode = this.navigator.getParent(this.blocks.statementInput3); + assert.equal(outNode, enclosingStatement); + }); + test('fromTopBlockToWorkspace', function () { + const outNode = this.navigator.getParent(this.blocks.statementInput1); + assert.equal(outNode, this.workspace); + }); + test('fromOutputBlockToWorkspace', function () { + const outNode = this.navigator.getParent(this.blocks.fieldWithOutput2); + assert.equal(outNode, this.workspace); + }); + test('fromOutputNextBlockToWorkspace', function () { + const inputConnection = this.blocks.secondBlock; + const outNode = this.navigator.getParent(this.blocks.outputNextBlock); + assert.equal(outNode, inputConnection); + }); + }); + }); +}); diff --git a/tests/mocha/keydown_test.js b/tests/mocha/shortcut_items_test.js similarity index 86% rename from tests/mocha/keydown_test.js rename to tests/mocha/shortcut_items_test.js index 0b72a7fee6b..29487ec5822 100644 --- a/tests/mocha/keydown_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -12,7 +12,7 @@ import { } from './test_helpers/setup_teardown.js'; import {createKeyDownEvent} from './test_helpers/user_input.js'; -suite('Key Down', function () { +suite('Keyboard Shortcut Items', function () { setup(function () { sharedTestSetup.call(this); this.workspace = Blockly.inject('blocklyDiv', {}); @@ -31,9 +31,22 @@ suite('Key Down', function () { defineStackBlock(); const block = workspace.newBlock('stack_block'); Blockly.common.setSelected(block); + sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(block); return block; } + /** + * Creates a block and sets its nextConnection as the focused node. + * @param {Blockly.Workspace} workspace The workspace to create a new block on. + */ + function setSelectedConnection(workspace) { + defineStackBlock(); + const block = workspace.newBlock('stack_block'); + sinon + .stub(Blockly.getFocusManager(), 'getFocusedNode') + .returns(block.nextConnection); + } + /** * Creates a test for not running keyDown events when the workspace is in read only mode. * @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent. @@ -42,7 +55,7 @@ suite('Key Down', function () { function runReadOnlyTest(keyEvent, opt_name) { const name = opt_name ? opt_name : 'Not called when readOnly is true'; test(name, function () { - this.workspace.options.readOnly = true; + this.workspace.setIsReadOnly(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.hideChaffSpy); }); @@ -72,9 +85,14 @@ suite('Key Down', function () { this.injectionDiv.dispatchEvent(this.event); sinon.assert.notCalled(this.hideChaffSpy); }); + test('Called when connection is focused', function () { + setSelectedConnection(this.workspace); + this.injectionDiv.dispatchEvent(this.event); + sinon.assert.calledOnce(this.hideChaffSpy); + }); }); - suite('Delete Block', function () { + suite('Delete', function () { setup(function () { this.hideChaffSpy = sinon.spy( Blockly.WorkspaceSvg.prototype, @@ -88,6 +106,7 @@ suite('Key Down', function () { ['Backspace', createKeyDownEvent(Blockly.utils.KeyCodes.BACKSPACE)], ]; // Delete a block. + // Note that chaff is hidden when a block is deleted. suite('Simple', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; @@ -107,6 +126,16 @@ suite('Key Down', function () { runReadOnlyTest(keyEvent, testCaseName); }); }); + // Do not delete anything if a connection is focused. + test('Not called when connection is focused', function () { + // Restore the stub behavior called during setup + Blockly.getFocusManager().getFocusedNode.restore(); + + setSelectedConnection(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.DELETE); + this.injectionDiv.dispatchEvent(event); + sinon.assert.notCalled(this.hideChaffSpy); + }); }); suite('Copy', function () { @@ -131,12 +160,6 @@ suite('Key Down', function () { Blockly.utils.KeyCodes.META, ]), ], - [ - 'Alt C', - createKeyDownEvent(Blockly.utils.KeyCodes.C, [ - Blockly.utils.KeyCodes.ALT, - ]), - ], ]; // Copy a block. suite('Simple', function () { @@ -199,6 +222,18 @@ suite('Key Down', function () { }); }); }); + test('Not called when connection is focused', function () { + // Restore the stub behavior called during setup + Blockly.getFocusManager().getFocusedNode.restore(); + + setSelectedConnection(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL, + ]); + this.injectionDiv.dispatchEvent(event); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); }); suite('Undo', function () { @@ -222,12 +257,6 @@ suite('Key Down', function () { Blockly.utils.KeyCodes.META, ]), ], - [ - 'Alt Z', - createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ - Blockly.utils.KeyCodes.ALT, - ]), - ], ]; // Undo. suite('Simple', function () { @@ -288,13 +317,6 @@ suite('Key Down', function () { Blockly.utils.KeyCodes.SHIFT, ]), ], - [ - 'Alt Shift Z', - createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ - Blockly.utils.KeyCodes.ALT, - Blockly.utils.KeyCodes.SHIFT, - ]), - ], ]; // Undo. suite('Simple', function () { diff --git a/tests/mocha/shortcut_registry_test.js b/tests/mocha/shortcut_registry_test.js index 37c1b9c2023..5641e17c7d1 100644 --- a/tests/mocha/shortcut_registry_test.js +++ b/tests/mocha/shortcut_registry_test.js @@ -5,6 +5,7 @@ */ import {assert} from '../../node_modules/chai/chai.js'; +import {createTestBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, @@ -259,7 +260,7 @@ suite('Keyboard Shortcut Registry Test', function () { assert.equal(this.registry.getKeyMap()['keyCode'][0], 'a'); }); test('Gets a copy of the registry', function () { - const shortcut = {'name': 'shortcutName'}; + const shortcut = {'name': 'shortcutName', 'keyCodes': ['2', '4']}; this.registry.register(shortcut); const registrycopy = this.registry.getRegistry(); registrycopy['shortcutName']['name'] = 'shortcutName1'; @@ -267,6 +268,10 @@ suite('Keyboard Shortcut Registry Test', function () { this.registry.getRegistry()['shortcutName']['name'], 'shortcutName', ); + assert.deepEqual( + this.registry.getRegistry()['shortcutName']['keyCodes'], + shortcut['keyCodes'], + ); }); test('Gets keyboard shortcuts from a key code', function () { this.registry.setKeyMap({'keyCode': ['shortcutName']}); @@ -299,7 +304,7 @@ suite('Keyboard Shortcut Registry Test', function () { 'callback': function () { return true; }, - 'precondition': function () { + 'preconditionFn': function () { return true; }, }; @@ -319,6 +324,27 @@ suite('Keyboard Shortcut Registry Test', function () { const event = createKeyDownEvent(Blockly.utils.KeyCodes.D); assert.isFalse(this.registry.onKeyDown(this.workspace, event)); }); + test('No callback if precondition fails', function () { + const shortcut = { + 'name': 'test_shortcut', + 'callback': function () { + return true; + }, + 'preconditionFn': function () { + return false; + }, + }; + const callBackStub = addShortcut( + this.registry, + shortcut, + Blockly.utils.KeyCodes.C, + true, + ); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C); + assert.isFalse(this.registry.onKeyDown(this.workspace, event)); + sinon.assert.notCalled(callBackStub); + }); + test('No precondition available - execute callback', function () { delete this.testShortcut['precondition']; const event = createKeyDownEvent(Blockly.utils.KeyCodes.C); @@ -332,8 +358,8 @@ suite('Keyboard Shortcut Registry Test', function () { 'callback': function () { return false; }, - 'precondition': function () { - return false; + 'preconditionFn': function () { + return true; }, }; const testShortcut2Stub = addShortcut( @@ -353,8 +379,8 @@ suite('Keyboard Shortcut Registry Test', function () { 'callback': function () { return false; }, - 'precondition': function () { - return false; + 'preconditionFn': function () { + return true; }, }; const testShortcut2Stub = addShortcut( @@ -367,6 +393,63 @@ suite('Keyboard Shortcut Registry Test', function () { sinon.assert.calledOnce(testShortcut2Stub); sinon.assert.notCalled(this.callBackStub); }); + suite('interaction with FocusManager', function () { + setup(function () { + this.testShortcutWithScope = { + 'name': 'test_shortcut', + 'callback': function (workspace, e, shortcut, scope) { + return true; + }, + 'preconditionFn': function (workspace, scope) { + return true; + }, + }; + + // Stub the focus manager + this.focusedBlock = createTestBlock(); + sinon + .stub(Blockly.getFocusManager(), 'getFocusedNode') + .returns(this.focusedBlock); + }); + test('Callback receives the focused node', function () { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C); + const callbackStub = addShortcut( + this.registry, + this.testShortcutWithScope, + Blockly.utils.KeyCodes.C, + true, + ); + this.registry.onKeyDown(this.workspace, event); + + const expectedScope = {focusedNode: this.focusedBlock}; + sinon.assert.calledWithExactly( + callbackStub, + this.workspace, + event, + this.testShortcutWithScope, + expectedScope, + ); + }); + test('Precondition receives the focused node', function () { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C); + const callbackStub = addShortcut( + this.registry, + this.testShortcutWithScope, + Blockly.utils.KeyCodes.C, + true, + ); + const preconditionStub = sinon + .stub(this.testShortcutWithScope, 'preconditionFn') + .returns(true); + this.registry.onKeyDown(this.workspace, event); + const expectedScope = {focusedNode: this.focusedBlock}; + sinon.assert.calledWithExactly( + preconditionStub, + this.workspace, + expectedScope, + ); + }); + }); }); suite('createSerializedKey', function () { diff --git a/tests/mocha/test_helpers/icon_mocks.js b/tests/mocha/test_helpers/icon_mocks.js index 039c4082f5c..5d117c71284 100644 --- a/tests/mocha/test_helpers/icon_mocks.js +++ b/tests/mocha/test_helpers/icon_mocks.js @@ -34,6 +34,22 @@ export class MockIcon { onLocationChange() {} onClick() {} + + getFocusableElement() { + throw new Error('Unsupported operation in mock.'); + } + + getFocusableTree() { + throw new Error('Unsupported operation in mock.'); + } + + onNodeFocus() {} + + onNodeBlur() {} + + canBeFocused() { + return false; + } } export class MockSerializableIcon extends MockIcon { diff --git a/tests/mocha/test_helpers/setup_teardown.js b/tests/mocha/test_helpers/setup_teardown.js index 2fc08cb6943..b0d7c83c697 100644 --- a/tests/mocha/test_helpers/setup_teardown.js +++ b/tests/mocha/test_helpers/setup_teardown.js @@ -5,6 +5,7 @@ */ import * as eventUtils from '../../../build/src/core/events/utils.js'; +import {FocusManager} from '../../../build/src/core/focus_manager.js'; /** * Safely disposes of Blockly workspace, logging any errors. @@ -124,6 +125,18 @@ export function sharedTestSetup(options = {}) { }; this.blockTypesCleanup_ = this.sharedCleanup.blockTypesCleanup_; this.messagesCleanup_ = this.sharedCleanup.messagesCleanup_; + + // Set up FocusManager to run in isolation for this test. + this.globalDocumentEventListeners = []; + const testState = this; + const addDocumentEventListener = function (type, listener) { + testState.globalDocumentEventListeners.push({type, listener}); + document.addEventListener(type, listener); + }; + const specificFocusManager = new FocusManager(addDocumentEventListener); + this.oldGetFocusManager = FocusManager.getFocusManager; + FocusManager.getFocusManager = () => specificFocusManager; + wrapDefineBlocksWithJsonArrayWithCleanup_(this.sharedCleanup); return { clock: this.clock, @@ -184,6 +197,16 @@ export function sharedTestTeardown() { } Blockly.WidgetDiv.testOnly_setDiv(null); + + // Remove the globally registered listener from FocusManager to avoid state + // being shared across test boundaries. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + this.globalDocumentEventListeners = []; + FocusManager.getFocusManager = this.oldGetFocusManager; } } diff --git a/tests/mocha/test_helpers/toolbox_definitions.js b/tests/mocha/test_helpers/toolbox_definitions.js index 427331bcf01..42de6cf2ec7 100644 --- a/tests/mocha/test_helpers/toolbox_definitions.js +++ b/tests/mocha/test_helpers/toolbox_definitions.js @@ -243,9 +243,8 @@ export function getBasicToolbox() { } export function getCollapsibleItem(toolbox) { - const contents = toolbox.contents_; - for (let i = 0; i < contents.length; i++) { - const item = contents[i]; + const contents = toolbox.contents.values(); + for (const item of contents) { if (item.isCollapsible()) { return item; } @@ -253,9 +252,8 @@ export function getCollapsibleItem(toolbox) { } export function getNonCollapsibleItem(toolbox) { - const contents = toolbox.contents_; - for (let i = 0; i < contents.length; i++) { - const item = contents[i]; + const contents = toolbox.contents.values(); + for (const item of contents) { if (!item.isCollapsible()) { return item; } diff --git a/tests/mocha/test_helpers/workspace.js b/tests/mocha/test_helpers/workspace.js index 40b2574fca1..917ce6f629e 100644 --- a/tests/mocha/test_helpers/workspace.js +++ b/tests/mocha/test_helpers/workspace.js @@ -100,9 +100,7 @@ export function testAWorkspace() { test('deleteVariableById(id2) one usage', function () { // Deleting variable one usage should not trigger confirm dialog. - const stub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, true); + const stub = sinon.stub(window, 'confirm').returns(true); this.workspace.deleteVariableById('id2'); sinon.assert.notCalled(stub); @@ -110,13 +108,13 @@ export function testAWorkspace() { assert.isNull(variable); assertVariableValues(this.workspace, 'name1', 'type1', 'id1'); assertBlockVarModelName(this.workspace, 0, 'name1'); + + stub.restore(); }); test('deleteVariableById(id1) multiple usages confirm', function () { // Deleting variable with multiple usages triggers confirm dialog. - const stub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, true); + const stub = sinon.stub(window, 'confirm').returns(true); this.workspace.deleteVariableById('id1'); sinon.assert.calledOnce(stub); @@ -124,13 +122,13 @@ export function testAWorkspace() { assert.isNull(variable); assertVariableValues(this.workspace, 'name2', 'type2', 'id2'); assertBlockVarModelName(this.workspace, 0, 'name2'); + + stub.restore(); }); test('deleteVariableById(id1) multiple usages cancel', function () { // Deleting variable with multiple usages triggers confirm dialog. - const stub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, false); + const stub = sinon.stub(window, 'confirm').returns(false); this.workspace.deleteVariableById('id1'); sinon.assert.calledOnce(stub); @@ -139,6 +137,8 @@ export function testAWorkspace() { assertBlockVarModelName(this.workspace, 0, 'name1'); assertBlockVarModelName(this.workspace, 1, 'name1'); assertBlockVarModelName(this.workspace, 2, 'name2'); + + stub.restore(); }); }); diff --git a/tests/mocha/toast_test.js b/tests/mocha/toast_test.js new file mode 100644 index 00000000000..45e02ad5de8 --- /dev/null +++ b/tests/mocha/toast_test.js @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Toasts', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.toastIsVisible = (message) => { + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + return !!(toast && toast.textContent === message); + }; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('can be shown', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message}); + assert.isTrue(this.toastIsVisible(message)); + }); + + test('can be shown only once per session', function () { + const options = { + message: 'texas toast', + id: 'test', + oncePerSession: true, + }; + Blockly.Toast.show(this.workspace, options); + assert.isTrue(this.toastIsVisible(options.message)); + Blockly.Toast.hide(this.workspace); + Blockly.Toast.show(this.workspace, options); + assert.isFalse(this.toastIsVisible(options.message)); + }); + + test('oncePerSession is ignored when false', function () { + const options = { + message: 'texas toast', + id: 'some id', + oncePerSession: true, + }; + Blockly.Toast.show(this.workspace, options); + assert.isTrue(this.toastIsVisible(options.message)); + Blockly.Toast.hide(this.workspace); + options.oncePerSession = false; + Blockly.Toast.show(this.workspace, options); + assert.isTrue(this.toastIsVisible(options.message)); + }); + + test('can be hidden', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message}); + assert.isTrue(this.toastIsVisible(message)); + Blockly.Toast.hide(this.workspace); + assert.isFalse(this.toastIsVisible(message)); + }); + + test('can be hidden by ID', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + assert.isTrue(this.toastIsVisible(message)); + Blockly.Toast.hide(this.workspace, 'test'); + assert.isFalse(this.toastIsVisible(message)); + }); + + test('hide does not hide toasts with different ID', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + assert.isTrue(this.toastIsVisible(message)); + Blockly.Toast.hide(this.workspace, 'test2'); + assert.isTrue(this.toastIsVisible(message)); + }); + + test('are shown for the designated duration', function () { + const clock = sinon.useFakeTimers(); + + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, duration: 3}); + for (let i = 0; i < 3; i++) { + assert.isTrue(this.toastIsVisible(message)); + clock.tick(1000); + } + assert.isFalse(this.toastIsVisible(message)); + + clock.restore(); + }); + + test('default to polite assertiveness', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + + assert.equal( + toast.getAttribute('aria-live'), + Blockly.Toast.Assertiveness.POLITE, + ); + }); + + test('respects assertiveness option', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, { + message, + id: 'test', + assertiveness: Blockly.Toast.Assertiveness.ASSERTIVE, + }); + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + + assert.equal( + toast.getAttribute('aria-live'), + Blockly.Toast.Assertiveness.ASSERTIVE, + ); + }); +}); diff --git a/tests/mocha/toolbox_test.js b/tests/mocha/toolbox_test.js index 3b69fac5dca..f32319c6779 100644 --- a/tests/mocha/toolbox_test.js +++ b/tests/mocha/toolbox_test.js @@ -47,13 +47,14 @@ suite('Toolbox', function () { test('Init called -> HtmlDiv is inserted before parent node', function () { const toolboxDiv = Blockly.common.getMainWorkspace().getInjectionDiv() .childNodes[0]; - assert.equal(toolboxDiv.className, 'blocklyToolboxDiv'); + assert.equal(toolboxDiv.className, 'blocklyToolbox'); }); test('Init called -> Toolbox is subscribed to background and foreground colour', function () { const themeManager = this.toolbox.workspace_.getThemeManager(); const themeManagerSpy = sinon.spy(themeManager, 'subscribe'); const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); + this.toolbox.dispose(); // Dispose of the old toolbox so that it can be reinited. this.toolbox.init(); sinon.assert.calledWith( themeManagerSpy, @@ -72,12 +73,14 @@ suite('Toolbox', function () { const renderSpy = sinon.spy(this.toolbox, 'render'); const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); + this.toolbox.dispose(); // Dispose of the old toolbox so that it can be reinited. this.toolbox.init(); sinon.assert.calledOnce(renderSpy); }); test('Init called -> Flyout is initialized', function () { const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); + this.toolbox.dispose(); // Dispose of the old toolbox so that it can be reinited. this.toolbox.init(); assert.isDefined(this.toolbox.getFlyout()); }); @@ -98,7 +101,7 @@ suite('Toolbox', function () { {'kind': 'category', 'contents': []}, ], }); - assert.lengthOf(this.toolbox.contents_, 2); + assert.equal(this.toolbox.contents.size, 2); sinon.assert.called(positionStub); }); // TODO: Uncomment once implemented. @@ -153,7 +156,7 @@ suite('Toolbox', function () { ], }; this.toolbox.render(jsonDef); - assert.lengthOf(this.toolbox.contents_, 1); + assert.equal(this.toolbox.contents.size, 1); }); test('multiple icon classes can be applied', function () { const jsonDef = { @@ -176,7 +179,7 @@ suite('Toolbox', function () { assert.doesNotThrow(() => { this.toolbox.render(jsonDef); }); - assert.lengthOf(this.toolbox.contents_, 1); + assert.equal(this.toolbox.contents.size, 1); }); }); @@ -198,11 +201,13 @@ suite('Toolbox', function () { sinon.assert.calledOnce(hideChaffStub); }); test('Category clicked -> Should select category', function () { - const categoryXml = document.getElementsByClassName('blocklyTreeRow')[0]; + const categoryXml = document.getElementsByClassName( + 'blocklyToolboxCategory', + )[0]; const evt = { 'target': categoryXml, }; - const item = this.toolbox.contentMap_[categoryXml.getAttribute('id')]; + const item = this.toolbox.contents.get(categoryXml.getAttribute('id')); const setSelectedSpy = sinon.spy(this.toolbox, 'setSelectedItem'); const onClickSpy = sinon.spy(item, 'onClick'); this.toolbox.onClick_(evt); @@ -354,14 +359,16 @@ suite('Toolbox', function () { assert.isFalse(handled); }); test('Next item is selectable -> Should select next item', function () { - const item = this.toolbox.contents_[0]; + const items = [...this.toolbox.contents.values()]; + const item = items[0]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectNext(); assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, this.toolbox.contents_[1]); + assert.equal(this.toolbox.selectedItem_, items[1]); }); test('Selected item is last item -> Should not handle event', function () { - const item = this.toolbox.contents_[this.toolbox.contents_.length - 1]; + const items = [...this.toolbox.contents.values()]; + const item = items.at(-1); this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectNext(); assert.isFalse(handled); @@ -385,15 +392,16 @@ suite('Toolbox', function () { assert.isFalse(handled); }); test('Selected item is first item -> Should not handle event', function () { - const item = this.toolbox.contents_[0]; + const item = [...this.toolbox.contents.values()][0]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectPrevious(); assert.isFalse(handled); assert.equal(this.toolbox.selectedItem_, item); }); test('Previous item is selectable -> Should select previous item', function () { - const item = this.toolbox.contents_[1]; - const prevItem = this.toolbox.contents_[0]; + const items = [...this.toolbox.contents.values()]; + const item = items[1]; + const prevItem = items[0]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectPrevious(); assert.isTrue(handled); @@ -402,9 +410,10 @@ suite('Toolbox', function () { test('Previous item is collapsed -> Should skip over children of the previous item', function () { const childItem = getChildItem(this.toolbox); const parentItem = childItem.getParent(); - const parentIdx = this.toolbox.contents_.indexOf(parentItem); + const items = [...this.toolbox.contents.values()]; + const parentIdx = items.indexOf(parentItem); // Gets the item after the parent. - const item = this.toolbox.contents_[parentIdx + 1]; + const item = items[parentIdx + 1]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectPrevious(); assert.isTrue(handled); @@ -726,9 +735,10 @@ suite('Toolbox', function () { }); test('Child categories visible if all ancestors expanded', function () { this.toolbox.render(getDeeplyNestedJSON()); - const outerCategory = this.toolbox.contents_[0]; - const middleCategory = this.toolbox.contents_[1]; - const innerCategory = this.toolbox.contents_[2]; + const items = [...this.toolbox.contents.values()]; + const outerCategory = items[0]; + const middleCategory = items[1]; + const innerCategory = items[2]; outerCategory.toggleExpanded(); middleCategory.toggleExpanded(); @@ -741,8 +751,9 @@ suite('Toolbox', function () { }); test('Child categories not visible if any ancestor not expanded', function () { this.toolbox.render(getDeeplyNestedJSON()); - const middleCategory = this.toolbox.contents_[1]; - const innerCategory = this.toolbox.contents_[2]; + const items = [...this.toolbox.contents.values()]; + const middleCategory = items[1]; + const innerCategory = items[2]; // Don't expand the outermost category // Even though the direct parent of inner is expanded, it shouldn't be visible diff --git a/tests/mocha/utils_test.js b/tests/mocha/utils_test.js index 97984fdbae4..b6228448db7 100644 --- a/tests/mocha/utils_test.js +++ b/tests/mocha/utils_test.js @@ -533,4 +533,25 @@ suite('Utils', function () { assert.equal(Blockly.utils.math.toDegrees(5 * quarter), 360 + 90, '450'); }); }); + + suite('deepMerge', function () { + test('Merges two objects', function () { + const target = {a: 1, b: '2', shared: 'this should be overwritten'}; + const source = {c: {deeplyNested: true}, shared: 'I overwrote it'}; + + const expected = {...target, ...source}; + const actual = Blockly.utils.object.deepMerge(target, source); + + assert.deepEqual(expected, actual); + }); + test('Merges objects with arrays', function () { + const target = {a: 1}; + const source = {b: ['orange', 'lime']}; + + const expected = {...target, ...source}; + const actual = Blockly.utils.object.deepMerge(target, source); + + assert.deepEqual(expected, actual); + }); + }); }); diff --git a/tests/mocha/variable_map_test.js b/tests/mocha/variable_map_test.js index 51f710c9921..c02887ceaca 100644 --- a/tests/mocha/variable_map_test.js +++ b/tests/mocha/variable_map_test.js @@ -21,7 +21,7 @@ suite('Variable Map', function () { setup(function () { sharedTestSetup.call(this); this.workspace = new Blockly.Workspace(); - this.variableMap = new Blockly.VariableMap(this.workspace); + this.variableMap = this.workspace.getVariableMap(); }); teardown(function () { @@ -39,17 +39,17 @@ suite('Variable Map', function () { this.variableMap.createVariable('name1', 'type1', 'id1'); // Assert there is only one variable in the this.variableMap. - let keys = Array.from(this.variableMap.variableMap.keys()); + let keys = this.variableMap.getTypes(); assert.equal(keys.length, 1); - let varMapLength = this.variableMap.variableMap.get(keys[0]).length; + let varMapLength = this.variableMap.getVariablesOfType(keys[0]).length; assert.equal(varMapLength, 1); this.variableMap.createVariable('name1', 'type1'); assertVariableValues(this.variableMap, 'name1', 'type1', 'id1'); // Check that the size of the variableMap did not change. - keys = Array.from(this.variableMap.variableMap.keys()); + keys = this.variableMap.getTypes(); assert.equal(keys.length, 1); - varMapLength = this.variableMap.variableMap.get(keys[0]).length; + varMapLength = this.variableMap.getVariablesOfType(keys[0]).length; assert.equal(varMapLength, 1); }); @@ -59,16 +59,16 @@ suite('Variable Map', function () { this.variableMap.createVariable('name1', 'type1', 'id1'); // Assert there is only one variable in the this.variableMap. - let keys = Array.from(this.variableMap.variableMap.keys()); + let keys = this.variableMap.getTypes(); assert.equal(keys.length, 1); - const varMapLength = this.variableMap.variableMap.get(keys[0]).length; + const varMapLength = this.variableMap.getVariablesOfType(keys[0]).length; assert.equal(varMapLength, 1); this.variableMap.createVariable('name1', 'type2', 'id2'); assertVariableValues(this.variableMap, 'name1', 'type1', 'id1'); assertVariableValues(this.variableMap, 'name1', 'type2', 'id2'); // Check that the size of the variableMap did change. - keys = Array.from(this.variableMap.variableMap.keys()); + keys = this.variableMap.getTypes(); assert.equal(keys.length, 2); }); @@ -187,24 +187,6 @@ suite('Variable Map', function () { }); }); - suite('getVariableTypes', function () { - test('Trivial', function () { - this.variableMap.createVariable('name1', 'type1', 'id1'); - this.variableMap.createVariable('name2', 'type1', 'id2'); - this.variableMap.createVariable('name3', 'type2', 'id3'); - this.variableMap.createVariable('name4', 'type3', 'id4'); - const resultArray = this.variableMap.getVariableTypes(); - // The empty string is always an option. - assert.deepEqual(resultArray, ['type1', 'type2', 'type3', '']); - }); - - test('None', function () { - // The empty string is always an option. - const resultArray = this.variableMap.getVariableTypes(); - assert.deepEqual(resultArray, ['']); - }); - }); - suite('getVariablesOfType', function () { test('Trivial', function () { const var1 = this.variableMap.createVariable('name1', 'type1', 'id1'); @@ -246,6 +228,65 @@ suite('Variable Map', function () { }); }); + suite( + 'Using changeVariableType to change the type of a variable', + function () { + test('updates it to a new non-empty value', function () { + const variable = this.variableMap.createVariable( + 'name1', + 'type1', + 'id1', + ); + this.variableMap.changeVariableType(variable, 'type2'); + const oldTypeVariables = this.variableMap.getVariablesOfType('type1'); + const newTypeVariables = this.variableMap.getVariablesOfType('type2'); + assert.deepEqual(oldTypeVariables, []); + assert.deepEqual(newTypeVariables, [variable]); + assert.equal(variable.getType(), 'type2'); + }); + + test('updates it to a new empty value', function () { + const variable = this.variableMap.createVariable( + 'name1', + 'type1', + 'id1', + ); + this.variableMap.changeVariableType(variable, ''); + const oldTypeVariables = this.variableMap.getVariablesOfType('type1'); + const newTypeVariables = this.variableMap.getVariablesOfType(''); + assert.deepEqual(oldTypeVariables, []); + assert.deepEqual(newTypeVariables, [variable]); + assert.equal(variable.getType(), ''); + }); + }, + ); + + suite('addVariable', function () { + test('normally', function () { + const variable = new Blockly.VariableModel(this.workspace, 'foo', 'int'); + assert.isNull(this.variableMap.getVariableById(variable.getId())); + this.variableMap.addVariable(variable); + assert.equal( + this.variableMap.getVariableById(variable.getId()), + variable, + ); + }); + }); + + suite('getTypes', function () { + test('when map is empty', function () { + const types = this.variableMap.getTypes(); + assert.deepEqual(types, []); + }); + + test('with various types', function () { + this.variableMap.createVariable('name1', 'type1', 'id1'); + this.variableMap.createVariable('name2', '', 'id2'); + const types = this.variableMap.getTypes(); + assert.deepEqual(types, ['type1', '']); + }); + }); + suite('getAllVariables', function () { test('Trivial', function () { const var1 = this.variableMap.createVariable('name1', 'type1', 'id1'); diff --git a/tests/mocha/variable_model_test.js b/tests/mocha/variable_model_test.js index 4ac533b65a9..cd2a89db420 100644 --- a/tests/mocha/variable_model_test.js +++ b/tests/mocha/variable_model_test.js @@ -27,8 +27,8 @@ suite('Variable Model', function () { 'test_type', 'test_id', ); - assert.equal(variable.name, 'test'); - assert.equal(variable.type, 'test_type'); + assert.equal(variable.getName(), 'test'); + assert.equal(variable.getType(), 'test_type'); assert.equal(variable.getId(), 'test_id'); }); @@ -39,7 +39,7 @@ suite('Variable Model', function () { null, 'test_id', ); - assert.equal(variable.type, ''); + assert.equal(variable.getType(), ''); }); test('Undefined type', function () { @@ -49,7 +49,7 @@ suite('Variable Model', function () { undefined, 'test_id', ); - assert.equal(variable.type, ''); + assert.equal(variable.getType(), ''); }); test('Null id', function () { @@ -59,8 +59,8 @@ suite('Variable Model', function () { 'test_type', null, ); - assert.equal(variable.name, 'test'); - assert.equal(variable.type, 'test_type'); + assert.equal(variable.getName(), 'test'); + assert.equal(variable.getType(), 'test_type'); assert.exists(variable.getId()); }); @@ -71,15 +71,15 @@ suite('Variable Model', function () { 'test_type', undefined, ); - assert.equal(variable.name, 'test'); - assert.equal(variable.type, 'test_type'); + assert.equal(variable.getName(), 'test'); + assert.equal(variable.getType(), 'test_type'); assert.exists(variable.getId()); }); test('Only name provided', function () { const variable = new Blockly.VariableModel(this.workspace, 'test'); - assert.equal(variable.name, 'test'); - assert.equal(variable.type, ''); + assert.equal(variable.getName(), 'test'); + assert.equal(variable.getType(), ''); assert.exists(variable.getId()); }); }); diff --git a/tests/mocha/xml_test.js b/tests/mocha/xml_test.js index c3ca2d4162e..218324197bf 100644 --- a/tests/mocha/xml_test.js +++ b/tests/mocha/xml_test.js @@ -531,28 +531,6 @@ suite('XML', function () { teardown(function () { workspaceTeardown.call(this, this.workspace); }); - suite('Dynamic Category Blocks', function () { - test('Untyped Variables', function () { - this.workspace.createVariable('name1', '', 'id1'); - const blocksArray = Blockly.Variables.flyoutCategoryBlocks( - this.workspace, - ); - for (let i = 0, xml; (xml = blocksArray[i]); i++) { - Blockly.Xml.domToBlock(xml, this.workspace); - } - }); - test('Typed Variables', function () { - this.workspace.createVariable('name1', 'String', 'id1'); - this.workspace.createVariable('name2', 'Number', 'id2'); - this.workspace.createVariable('name3', 'Colour', 'id3'); - const blocksArray = Blockly.VariablesDynamic.flyoutCategoryBlocks( - this.workspace, - ); - for (let i = 0, xml; (xml = blocksArray[i]); i++) { - Blockly.Xml.domToBlock(xml, this.workspace); - } - }); - }); suite('Comments', function () { suite('Headless', function () { test('Text', function () { @@ -910,36 +888,4 @@ suite('XML', function () { }); }); }); - suite('generateVariableFieldDom', function () { - test('Case Sensitive', function () { - const varId = 'testId'; - const type = 'testType'; - const name = 'testName'; - - const mockVariableModel = { - type: type, - name: name, - getId: function () { - return varId; - }, - }; - - const generatedXml = Blockly.Xml.domToText( - Blockly.Variables.generateVariableFieldDom(mockVariableModel), - ); - const expectedXml = - '' + - name + - ''; - assert.equal(generatedXml, expectedXml); - }); - }); }); diff --git a/tests/node/run_node_test.mjs b/tests/node/run_node_test.mjs index ee95fd5282f..c6d21506987 100644 --- a/tests/node/run_node_test.mjs +++ b/tests/node/run_node_test.mjs @@ -37,6 +37,106 @@ const xmlText = ' \n' + ''; +const json = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'procedures_defnoreturn', + 'id': '0!;|f{%4H@mgQ`SIEDKV', + 'x': 38, + 'y': 163, + 'icons': { + 'comment': { + 'text': 'Describe this function...', + 'pinned': false, + 'height': 80, + 'width': 160, + }, + }, + 'fields': { + 'NAME': 'say hello', + }, + 'inputs': { + 'STACK': { + 'block': { + 'type': 'text_print', + 'id': 't^`WoL~R$t}rk]`JVFUP', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': '_PxHV1tqEy60kP^].Qhh', + 'fields': { + 'TEXT': 'abc', + }, + }, + 'block': { + 'type': 'text_join', + 'id': 'K4.OZ9ql9j0f367238R@', + 'extraState': { + 'itemCount': 2, + }, + 'inputs': { + 'ADD0': { + 'block': { + 'type': 'text', + 'id': '5ElufS^j4;l:9N#|Yt$X', + 'fields': { + 'TEXT': 'The meaning of life is', + }, + }, + }, + 'ADD1': { + 'block': { + 'type': 'math_arithmetic', + 'id': ',QfcN`h]rQ86a]6J|di1', + 'fields': { + 'OP': 'MINUS', + }, + 'inputs': { + 'A': { + 'shadow': { + 'type': 'math_number', + 'id': 'ClcKUIPYleVQ_j7ZjK]^', + 'fields': { + 'NUM': 44, + }, + }, + }, + 'B': { + 'shadow': { + 'type': 'math_number', + 'id': 'F_cU|uaP7oB-k(j~@X?g', + 'fields': { + 'NUM': 2, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'procedures_callnoreturn', + 'id': 'Ad^$sruQ.`6zNmQ6jPit', + 'x': 38, + 'y': 113, + 'extraState': { + 'name': 'say hello', + }, + }, + ], + }, +}; + suite('Test Node.js', function () { test('Import XML', function () { const xml = Blockly.utils.xml.textToDom(xmlText); @@ -69,4 +169,23 @@ suite('Test Node.js', function () { // Check output assert.equal("window.alert('Hello from Blockly!');", code.trim(), 'equal'); }); + test('Import JSON', function () { + const workspace = new Blockly.Workspace(); + Blockly.serialization.workspaces.load(json, workspace); + }); + test('Roundtrip JSON', function () { + const workspace = new Blockly.Workspace(); + Blockly.serialization.workspaces.load(json, workspace); + + const jsonAfter = Blockly.serialization.workspaces.save(workspace); + + assert.deepEqual(jsonAfter, json); + }); + test('Dropdown getText works with no HTMLElement defined', function () { + const field = new Blockly.FieldDropdown([ + ['firstOption', '1'], + ['secondOption', '2'], + ]); + assert.equal(field.getText(), 'firstOption'); + }); }); diff --git a/tsdoc.json b/tsdoc.json index 51900e7c369..5470830e746 100644 --- a/tsdoc.json +++ b/tsdoc.json @@ -3,10 +3,6 @@ // Include the definitions that are required for API Extractor "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], "tagDefinitions": [ - { - "tagName": "@alias", - "syntaxKind": "block" - }, { "tagName": "@define", "syntaxKind": "block" @@ -18,18 +14,12 @@ { "tagName": "@nocollapse", "syntaxKind": "modifier" - }, - { - "tagName": "@suppress", - "syntaxKind": "modifier" } ], "supportForTags": { - "@alias": true, "@define": true, "@license": true, - "@nocollapse": true, - "@suppress": true + "@nocollapse": true } }