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..7284973d9cc 100644 --- a/blocks/procedures.ts +++ b/blocks/procedures.ts @@ -31,11 +31,14 @@ 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_; }, /** diff --git a/blocks/variables.ts b/blocks/variables.ts index 0ec9112a3d6..4651c43ec29 100644 --- a/blocks/variables.ts +++ b/blocks/variables.ts @@ -165,11 +165,12 @@ 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); + } + (block.workspace as WorkspaceSvg).refreshToolboxSelection(); }; }; diff --git a/blocks/variables_dynamic.ts b/blocks/variables_dynamic.ts index 8e4ce290e09..a6e78ef4a34 100644 --- a/blocks/variables_dynamic.ts +++ b/blocks/variables_dynamic.ts @@ -144,9 +144,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 +176,12 @@ 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); + } + (block.workspace as WorkspaceSvg).refreshToolboxSelection(); }; }; diff --git a/core/block.ts b/core/block.ts index 0face8f8c9b..b95427bce4e 100644 --- a/core/block.ts +++ b/core/block.ts @@ -43,6 +43,10 @@ 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'; @@ -51,7 +55,6 @@ 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'; /** @@ -792,7 +795,7 @@ export class Block implements IASTNodeLocation { this.deletable && !this.shadow && !this.isDeadOrDying() && - !this.workspace.options.readOnly + !this.workspace.isReadOnly() ); } @@ -825,7 +828,7 @@ export class Block implements IASTNodeLocation { this.movable && !this.shadow && !this.isDeadOrDying() && - !this.workspace.options.readOnly + !this.workspace.isReadOnly() ); } @@ -914,7 +917,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 +937,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 +1105,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 +1133,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 +1147,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 +1171,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 +1191,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,7 +1411,7 @@ export class Block implements IASTNodeLocation { return this.disabledReasons.size === 0; } - /** @deprecated v11 - Get whether the block is manually disabled. */ + /** @deprecated v11 - Get or sets whether the block is manually disabled. */ private get disabled(): boolean { deprecation.warn( 'disabled', @@ -1419,7 +1422,6 @@ export class Block implements IASTNodeLocation { return this.hasDisabledReason(constants.MANUALLY_DISABLED); } - /** @deprecated v11 - Set whether the block is manually disabled. */ private set disabled(value: boolean) { deprecation.warn( 'disabled', @@ -2516,7 +2518,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..49f65c1f38e --- /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, true); + } + + /** + * 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 10fa995ffda..b8712b01914 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,7 +33,6 @@ 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 {IconType} from './icons/icon_types.js'; import {MutatorIcon} from './icons/mutator_icon.js'; @@ -42,12 +40,12 @@ 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 {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'; @@ -75,6 +73,7 @@ export class BlockSvg implements IASTNodeLocationSvg, IBoundedElement, + IContextMenu, ICopyable, IDraggable, IDeletable @@ -194,6 +193,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); @@ -228,7 +230,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); } @@ -529,9 +531,12 @@ export class BlockSvg if (!collapsed) { this.updateDisabled(); this.removeInput(collapsedInputName); + dom.removeClass(this.svgGroup, 'blocklyCollapsed'); return; } + dom.addClass(this.svgGroup, 'blocklyCollapsed'); + const text = this.toString(internalConstants.COLLAPSE_CHARS); const field = this.getField(collapsedFieldName); if (field) { @@ -544,41 +549,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); @@ -603,15 +581,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. @@ -622,17 +600,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); } } @@ -676,6 +694,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. @@ -688,10 +724,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++) { @@ -716,6 +752,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(); @@ -873,17 +916,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(); } } @@ -1075,6 +1116,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. @@ -1139,7 +1194,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; @@ -1157,16 +1212,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); } @@ -1681,6 +1742,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; @@ -1736,4 +1807,16 @@ 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'], + ); + } + } } diff --git a/core/blockly.ts b/core/blockly.ts index 01490dbb694..c38a1d48e4b 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,16 +102,26 @@ 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 {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'; @@ -132,6 +145,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 +170,12 @@ 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 {CursorOptions, 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 +432,11 @@ Names.prototype.populateProcedures = function ( }; // clang-format on +export * from './toast.js'; + // Re-export submodules that no longer declareLegacyNamespace. export { ASTNode, - BasicCursor, Block, BlockSvg, BlocklyOptions, @@ -435,11 +451,12 @@ export { ContextMenuItems, ContextMenuRegistry, Css, - Cursor, + CursorOptions, DeleteArea, DragTarget, Events, Extensions, + LineCursor, Procedures, ShortcutItems, Themes, @@ -471,6 +488,8 @@ export { }; export const DropDownDiv = dropDownDiv; export { + BlockFlyoutInflater, + ButtonFlyoutInflater, CodeGenerator, Field, FieldCheckbox, @@ -504,7 +523,10 @@ export { FieldVariableValidator, Flyout, FlyoutButton, + FlyoutItem, FlyoutMetricsManager, + FlyoutSeparator, + FocusManager, CodeGenerator as Generator, Gesture, Grid, @@ -529,6 +551,9 @@ export { IDraggable, IDragger, IFlyout, + IFlyoutInflater, + IFocusableNode, + IFocusableTree, IHasBubble, IIcon, IKeyboardAccessible, @@ -546,9 +571,13 @@ export { IToolbox, IToolboxItem, IVariableBackedParameterModel, + IVariableMap, + IVariableModel, + IVariableState, + ImageProperties, Input, - InsertionMarkerManager, InsertionMarkerPreviewer, + LabelFlyoutInflater, LayerManager, Marker, MarkerManager, @@ -562,10 +591,11 @@ export { Names, Options, RenderedConnection, + ReturnEphemeralFocus, Scrollbar, ScrollbarPair, + SeparatorFlyoutInflater, ShortcutRegistry, - TabNavigateCursor, Theme, ThemeManager, Toolbox, @@ -583,6 +613,7 @@ export { WorkspaceSvg, ZoomControls, config, + getFocusManager, hasBubble, icons, inject, diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index bac94dbc8a0..645e74a60d3 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -106,11 +106,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( 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..cb13d5caecb 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 = ''; @@ -123,6 +126,11 @@ export class TextInputBubble extends Bubble { this.sizeChangeListeners.push(listener); } + /** Adds a change listener to be notified when this bubble's location changes. */ + addLocationChangeListener(listener: () => void) { + this.locationChangeListeners.push(listener); + } + /** Creates the editor UI for this bubble. */ private createEditor(container: SVGGElement): { inputRoot: SVGForeignObjectElement; @@ -230,10 +238,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(); @@ -316,6 +339,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..665ce7a2425 --- /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, true); + } + + /** + * 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/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..bcb650b26ff 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -26,6 +26,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'; @@ -105,6 +106,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 @@ -278,12 +284,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. */ 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/connection.ts b/core/connection.ts index 9cc2c28a923..039d8822c01 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -485,7 +485,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..4ba09de8231 100644 --- a/core/contextmenu.ts +++ b/core/contextmenu.ts @@ -18,9 +18,11 @@ import type { import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.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); diff --git a/core/contextmenu_items.ts b/core/contextmenu_items.ts index 58429fb1381..267305e2121 100644 --- a/core/contextmenu_items.ts +++ b/core/contextmenu_items.ts @@ -614,15 +614,20 @@ 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, ), ); diff --git a/core/contextmenu_registry.ts b/core/contextmenu_registry.ts index fb0d899d141..fc7a94dcb08 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,111 @@ 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; + // TODO(#8839): Remove any once Block, etc. implement IFocusableNode + focusedNode?: IFocusableNode | any; } /** - * 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 +260,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..6ca262f3b25 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,26 @@ input[type=number] { z-index: 80; pointer-events: none; } + +.blocklyField { + cursor: default; +} + +.blocklyInputField { + cursor: text; +} + +.blocklyDragging .blocklyField, +.blocklyDragging .blocklyIconGroup { + cursor: grabbing; +} + +.blocklyActiveFocus { + outline-color: #2ae; + outline-width: 2px; +} +.blocklyPassiveFocus { + outline-color: #3fdfff; + outline-width: 1.5px; +} `; diff --git a/core/dialog.ts b/core/dialog.ts index 7e21129855c..374961323da 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -6,24 +6,29 @@ // 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, @@ -31,6 +36,11 @@ let promptImplementation = function ( 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 +55,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 +75,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 +108,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 +116,45 @@ export function prompt( /** * Sets the function to be run when Blockly.dialog.prompt() is called. * - * @param promptFunction The function to be run. + * @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..b53c131653b 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(); @@ -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(); @@ -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.workspace.setResizesEnabled(true); + eventUtils.setGroup(newGroup); } /** Connects the given candidate connections. */ 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..518351d5c86 100644 --- a/core/dragging/dragger.ts +++ b/core/dragging/dragger.ts @@ -31,6 +31,9 @@ export class Dragger implements IDragger { /** Handles any drag startup. */ onDragStart(e: PointerEvent) { + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } this.draggable.startDrag(e); } @@ -119,13 +122,13 @@ 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); } // We need to special case blocks for now so that we look at the root block diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index f9af02ac9f7..0d259bc53d7 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -133,15 +133,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 +157,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(); } /** @@ -344,12 +335,8 @@ 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); // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. @@ -651,16 +638,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,14 +645,6 @@ 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(); } @@ -703,19 +672,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..ae3b9e6b21f 100644 --- a/core/events/events.ts +++ b/core/events/events.ts @@ -40,12 +40,15 @@ 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'; 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..3140f141004 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(); } /** diff --git a/core/events/events_var_delete.ts b/core/events/events_var_delete.ts index caaa1f4874a..225459c44c7 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(); } /** diff --git a/core/events/events_var_rename.ts b/core/events/events_var_rename.ts index b461184cab1..23a0a17cdc2 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; } 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/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/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..725a2867d9e 100644 --- a/core/field.ts +++ b/core/field.ts @@ -83,9 +83,6 @@ export abstract class Field */ DEFAULT_VALUE: T | null = null; - /** Non-breaking space. */ - static readonly NBSP = '\u00A0'; - /** * A value used to signal when a field's constructor should *not* set the * field's value or run configure_, and should allow a subclass to do that @@ -194,9 +191,6 @@ export abstract class Field */ SERIALIZABLE = false; - /** Mouse cursor style when over the hotspot that initiates the editor. */ - CURSOR = ''; - /** * @param value The initial value of the field. * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by @@ -324,6 +318,9 @@ export abstract class Field protected initView() { this.createBorderRect_(); this.createTextElement_(); + if (this.fieldGroup_) { + dom.addClass(this.fieldGroup_, 'blocklyField'); + } } /** @@ -339,8 +336,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 +373,7 @@ export abstract class Field this.textElement_ = dom.createSvgElement( Svg.TEXT, { - 'class': 'blocklyText', + 'class': 'blocklyText blocklyFieldText', }, this.fieldGroup_, ); @@ -406,7 +405,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 +417,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 +435,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 +449,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 +511,6 @@ export abstract class Field /** * Dispose of all DOM objects and events belonging to this editable field. - * - * @internal */ dispose() { dropDownDiv.hideIfOwner(this); @@ -538,13 +531,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 +824,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 +904,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,16 +967,10 @@ 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) + '…'; } - // Replace whitespace with non-breaking spaces so the text doesn't collapse. - text = text.replace(/\s/g, Field.NBSP); if (this.sourceBlock_ && this.sourceBlock_.RTL) { // The SVG is LTR, force text to be RTL by adding an RLM. text += '\u200F'; @@ -1057,8 +1026,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 +1284,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 +1292,6 @@ export abstract class Field /** * Refresh the variable name referenced by this field if this field references * variables. - * - * @internal */ refreshVariableName() {} // NOP @@ -1369,15 +1333,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. * 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 bc2d2856f7d..81279e2a1f5 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. */ @@ -213,6 +206,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'); + } } /** @@ -277,16 +275,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) { @@ -297,18 +297,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(); @@ -327,13 +324,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; @@ -494,7 +497,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(); @@ -541,12 +544,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; @@ -579,12 +577,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; @@ -642,8 +635,10 @@ 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 (option instanceof HTMLElement) { + return option.title ?? option.ariaLabel ?? option.innerText; } return option; } @@ -681,7 +676,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]; } @@ -689,10 +687,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]; }); @@ -762,28 +759,29 @@ 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]) && + !(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}`, ); } } @@ -793,6 +791,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. */ @@ -804,11 +823,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..650575f59a3 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -151,6 +151,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..2cdd8056553 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -100,9 +100,6 @@ export abstract class FieldInput extends Field< */ override SERIALIZABLE = true; - /** Mouse cursor style when over the hotspot that initiates the editor. */ - override CURSOR = 'text'; - /** * @param value The initial value of the field. Should cast to a string. * Defaults to an empty string if null or undefined. Also accepts @@ -149,9 +146,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(); @@ -406,7 +407,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 +417,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 +502,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'); } /** @@ -561,11 +562,6 @@ 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(); } } @@ -673,15 +669,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..2af3c4d057a 100644 --- a/core/field_variable.ts +++ b/core/field_variable.ts @@ -23,14 +23,16 @@ 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 {WorkspaceSvg} from './workspace_svg.js'; import * as Xml from './xml.js'; /** @@ -51,7 +53,7 @@ export class FieldVariable extends FieldDropdown { 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 +71,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 +84,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 +151,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 +198,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 +226,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 +251,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 +309,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 +320,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 +376,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 +424,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 +458,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 +484,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 +515,17 @@ 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_); + if (workspace instanceof WorkspaceSvg) { + workspace.refreshToolboxSelection(); + } return; } } @@ -554,24 +577,35 @@ 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.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..e738470a606 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -11,46 +11,32 @@ */ // 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 {FlyoutSeparator, SeparatorAxis} from './flyout_separator.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 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. @@ -85,12 +71,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 +85,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 +109,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 +128,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 +146,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 +160,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 +217,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. @@ -310,15 +263,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; @@ -403,8 +348,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 +391,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(); @@ -570,16 +510,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 +594,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 +626,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 +658,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 +672,60 @@ 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, + false, + ), + ); } } } - 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 +752,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 +786,7 @@ export abstract class Flyout * @internal */ isBlockCreatable(block: BlockSvg): boolean { - return block.isEnabled(); + return block.isEnabled() && !this.getTargetWorkspace().isReadOnly(); } /** @@ -1149,123 +832,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 +936,29 @@ 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; + } } diff --git a/core/flyout_button.ts b/core/flyout_button.ts index b03a8d9615c..3b9b2fe0735 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -14,9 +14,12 @@ 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 {IRenderedElement} from './interfaces/i_rendered_element.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 +28,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 IASTNodeLocationSvg, IBoundedElement, IRenderedElement +{ /** The horizontal margin around the text in the button. */ static TEXT_MARGIN_X = 5; @@ -41,7 +46,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 +57,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; @@ -92,14 +98,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'; @@ -179,7 +177,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 +196,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 +242,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 +268,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 +308,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); } @@ -342,6 +374,21 @@ 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; + } } /** 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..d501ceedbbf --- /dev/null +++ b/core/flyout_item.ts @@ -0,0 +1,42 @@ +import type {IBoundedElement} from './interfaces/i_bounded_element.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. + * @param focusable True if the element should be allowed to be focused by + * e.g. keyboard navigation in the flyout. + */ + constructor( + private element: IBoundedElement, + private type: string, + private focusable: boolean, + ) {} + + /** + * Returns the element displayed in the flyout. + */ + getElement() { + return this.element; + } + + /** + * Returns the type of flyout element this item represents. + */ + getType() { + return this.type; + } + + /** + * Returns whether or not the flyout element can receive focus. + */ + isFocusable() { + return this.focusable; + } +} diff --git a/core/flyout_separator.ts b/core/flyout_separator.ts new file mode 100644 index 00000000000..733371007a1 --- /dev/null +++ b/core/flyout_separator.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; +import {Rect} from './utils/rect.js'; + +/** + * Representation of a gap between elements in a flyout. + */ +export class FlyoutSeparator implements IBoundedElement { + 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; + } +} + +/** + * 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..88eef46b530 --- /dev/null +++ b/core/focus_manager.ts @@ -0,0 +1,459 @@ +/** + * @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'; + + focusedNode: IFocusableNode | null = null; + registeredTrees: Array = []; + + private currentlyHoldsEphemeralFocus: boolean = false; + private lockFocusStateChanges: boolean = false; + + constructor( + addGlobalEventListener: (type: string, listener: EventListener) => void, + ) { + // Register root document focus listeners for tracking when focus leaves all + // tracked focusable trees. + addGlobalEventListener('focusin', (event) => { + if (!(event instanceof FocusEvent)) return; + + // The target that now has focus. + const activeElement = document.activeElement; + let newNode: IFocusableNode | null | undefined = null; + if ( + activeElement instanceof HTMLElement || + activeElement instanceof SVGElement + ) { + // If the target losing focus maps to any tree, then it should be + // updated. Per the contract of findFocusableNodeFor only one tree + // should claim the element. + for (const tree of this.registeredTrees) { + newNode = FocusableTreeTraverser.findFocusableNodeFor( + activeElement, + tree, + ); + if (newNode) break; + } + } + + if (newNode) { + 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(); + } + }); + } + + /** + * 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.focusedNode = 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 selected 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). + * + * @param focusableNode The node that should receive active focus. + */ + focusNode(focusableNode: IFocusableNode): void { + this.ensureManagerIsUnlocked(); + if (this.focusedNode === focusableNode) return; // State is unchanged. + + 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 (since this can + // cause weird inconsistencies). + const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( + focusableNode.getFocusableElement(), + nextTree, + ); + if (matchedNode !== focusableNode) { + throw Error( + `Attempting to focus node which isn't recognized by its parent tree: ` + + `${focusableNode}.`, + ); + } + + const prevNode = this.focusedNode; + const prevTree = prevNode?.getFocusableTree(); + if (prevNode && prevTree !== nextTree) { + this.passivelyFocusNode(prevNode, nextTree); + } + + // If there's a focused node in the new node's tree, ensure it's reset. + const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree); + 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 !== focusableNode) { + this.removeHighlight(nextTreeRoot); + } + + if (!this.currentlyHoldsEphemeralFocus) { + // Only change the actively focused node if ephemeral state isn't held. + this.activelyFocusNode(focusableNode, prevTree ?? null); + } + this.focusedNode = focusableNode; + } + + /** + * Ephemerally captures focus for a selected 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); + } + }; + } + + /** + * 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.', + ); + } + } + + /** + * 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.focusedNode = 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; + 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; + node.getFocusableTree().onTreeBlur(nextTree); + node.onNodeBlur(); + this.lockFocusStateChanges = false; + + 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); + } +} + +let 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. + */ +export function getFocusManager(): FocusManager { + if (!focusManager) { + focusManager = new FocusManager(document.addEventListener); + } + return focusManager; +} 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..fc23ba7ca15 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -894,7 +894,7 @@ 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); } 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..b546769647e 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -55,6 +55,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 +111,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 +147,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 +193,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 +242,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 +295,12 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { } } + onBubbleLocationChange(): void { + if (this.textInputBubble) { + this.bubbleLocation = this.textInputBubble.getRelativeToSurfaceXY(); + } + } + bubbleIsVisible(): boolean { return this.bubbleVisiblity; } @@ -313,6 +362,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 +412,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/mutator_icon.ts b/core/icons/mutator_icon.ts index eea533eab4a..d8d91bea98b 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -118,7 +118,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 { diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts index b82ad10971d..87d932bb5fe 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -90,7 +90,7 @@ export class WarningIcon extends Icon implements IHasBubble { }, this.svgRoot, ); - dom.addClass(this.svgRoot!, 'blockly-icon-warning'); + dom.addClass(this.svgRoot!, 'blocklyWarningIcon'); } override dispose() { diff --git a/core/inject.ts b/core/inject.ts index 40016bc23f4..de78fbfae75 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -98,7 +98,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. @@ -141,7 +141,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 +158,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_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..42204775ece 100644 --- a/core/interfaces/i_flyout.ts +++ b/core/interfaces/i_flyout.ts @@ -7,7 +7,7 @@ // 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'; 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..53a432d30f4 --- /dev/null +++ b/core/interfaces/i_focusable_node.ts @@ -0,0 +1,79 @@ +/** + * @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 to the + * element relative to its nearest IFocusableTree parent. It 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). + */ + 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. + */ + 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; +} + +/** + * 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 + ); +} diff --git a/core/interfaces/i_focusable_tree.ts b/core/interfaces/i_focusable_tree.ts new file mode 100644 index 00000000000..69afa24ffdf --- /dev/null +++ b/core/interfaces/i_focusable_tree.ts @@ -0,0 +1,140 @@ +/** + * @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. + * + * 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_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_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_toolbox.ts b/core/interfaces/i_toolbox.ts index 2756099ec34..1780af94d8a 100644 --- a/core/interfaces/i_toolbox.ts +++ b/core/interfaces/i_toolbox.ts @@ -94,7 +94,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 +107,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_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 index 3b0efae3fb3..009e8f5b1e4 100644 --- a/core/keyboard_nav/ast_node.ts +++ b/core/keyboard_nav/ast_node.ts @@ -13,11 +13,12 @@ // Former goog.module ID: Blockly.ASTNode import {Block} from '../block.js'; +import {BlockSvg} from '../block_svg.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 {FlyoutItem} from '../flyout_item.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'; @@ -46,9 +47,7 @@ export class ASTNode { 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; + private wsCoordinate: Coordinate | null = null; /** * @param type The type of the location. @@ -118,7 +117,7 @@ export class ASTNode { * @returns The workspace coordinate or null if the location is not a * workspace. */ - getWsCoordinate(): Coordinate { + getWsCoordinate(): Coordinate | null { return this.wsCoordinate; } @@ -143,12 +142,12 @@ export class ASTNode { 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 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; for (let j = 0; j < fieldRow.length; j++) { const field = fieldRow[j]; @@ -208,12 +207,12 @@ export class ASTNode { 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); + 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]; + const input = block.inputList[i]; if (input.connection && input !== parentInput) { return ASTNode.createInputNode(input); } @@ -347,10 +346,11 @@ export class ASTNode { ); 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); + const element = nextItem.getElement(); + if (element instanceof FlyoutButton) { + return ASTNode.createButtonNode(element); + } else if (element instanceof BlockSvg) { + return ASTNode.createStackNode(element); } return null; @@ -370,12 +370,15 @@ export class ASTNode { forward: boolean, ): FlyoutItem | null { const currentIndex = flyoutContents.findIndex((item: FlyoutItem) => { - if (currentLocation instanceof Block && item.block === currentLocation) { + if ( + currentLocation instanceof BlockSvg && + item.getElement() === currentLocation + ) { return true; } if ( currentLocation instanceof FlyoutButton && - item.button === currentLocation + item.getElement() === currentLocation ) { return true; } @@ -384,7 +387,17 @@ export class ASTNode { if (currentIndex < 0) return null; - const resultIndex = forward ? currentIndex + 1 : currentIndex - 1; + let resultIndex = forward ? currentIndex + 1 : currentIndex - 1; + // The flyout may contain non-focusable elements like spacers or custom + // items. If the next/previous element is one of those, keep looking until a + // focusable element is encountered. + while ( + resultIndex >= 0 && + resultIndex < flyoutContents.length && + !flyoutContents[resultIndex].isFocusable() + ) { + resultIndex += forward ? 1 : -1; + } if (resultIndex === -1 || resultIndex === flyoutContents.length) { return null; } @@ -425,18 +438,11 @@ export class ASTNode { // substack. const topBlock = block.getTopStackBlock(); const topConnection = getParentConnection(topBlock); + const parentInput = topConnection?.targetConnection?.getParentInput(); // 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, - ); + if (parentInput) { + return ASTNode.createInputNode(parentInput); } else { // Go to stack level if you are not underneath an input. return ASTNode.createStackNode(topBlock); @@ -455,6 +461,8 @@ export class ASTNode { const inputs = block.inputList; for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; + if (!input.isVisible()) continue; + const fieldRow = input.fieldRow; for (let j = 0; j < fieldRow.length; j++) { const field = fieldRow[j]; @@ -523,7 +531,9 @@ export class ASTNode { case ASTNode.types.NEXT: { const connection = this.location as Connection; const targetConnection = connection.targetConnection; - return ASTNode.createConnectionNode(targetConnection!); + return targetConnection + ? ASTNode.createConnectionNode(targetConnection) + : null; } case ASTNode.types.BUTTON: return this.navigateFlyoutContents(true); @@ -560,7 +570,9 @@ export class ASTNode { case ASTNode.types.INPUT: { const connection = this.location as Connection; const targetConnection = connection.targetConnection; - return ASTNode.createConnectionNode(targetConnection!); + return targetConnection + ? ASTNode.createConnectionNode(targetConnection) + : null; } } @@ -693,10 +705,7 @@ export class ASTNode { * @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; - } + static createFieldNode(field: Field): ASTNode { return new ASTNode(ASTNode.types.FIELD, field); } @@ -709,25 +718,14 @@ export class ASTNode { * @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() + const parentInput = connection.getParentInput(); + if ( + (type === ConnectionType.INPUT_VALUE || + type === ConnectionType.NEXT_STATEMENT) && + parentInput ) { - // AnyDuringMigration because: Argument of type 'Input | null' is not - // assignable to parameter of type 'Input'. - return ASTNode.createInputNode( - connection.getParentInput() as AnyDuringMigration, - ); + return ASTNode.createInputNode(parentInput); } else if (type === ConnectionType.NEXT_STATEMENT) { return new ASTNode(ASTNode.types.NEXT, connection); } else if (type === ConnectionType.OUTPUT_VALUE) { @@ -746,7 +744,7 @@ export class ASTNode { * @returns An AST node pointing to a input. */ static createInputNode(input: Input): ASTNode | null { - if (!input || !input.connection) { + if (!input.connection) { return null; } return new ASTNode(ASTNode.types.INPUT, input.connection); @@ -758,10 +756,7 @@ export class ASTNode { * @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; - } + static createBlockNode(block: Block): ASTNode { return new ASTNode(ASTNode.types.BLOCK, block); } @@ -775,10 +770,7 @@ export class ASTNode { * @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; - } + static createStackNode(topBlock: Block): ASTNode { return new ASTNode(ASTNode.types.STACK, topBlock); } @@ -791,10 +783,7 @@ export class ASTNode { * @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; - } + static createButtonNode(button: FlyoutButton): ASTNode { return new ASTNode(ASTNode.types.BUTTON, button); } @@ -807,12 +796,9 @@ export class ASTNode { * workspace. */ static createWorkspaceNode( - workspace: Workspace | null, - wsCoordinate: Coordinate | null, - ): ASTNode | null { - if (!wsCoordinate || !workspace) { - return null; - } + workspace: Workspace, + wsCoordinate: Coordinate, + ): ASTNode { const params = {wsCoordinate}; return new ASTNode(ASTNode.types.WORKSPACE, workspace, params); } 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/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/line_cursor.ts b/core/keyboard_nav/line_cursor.ts new file mode 100644 index 00000000000..2025da7bf06 --- /dev/null +++ b/core/keyboard_nav/line_cursor.ts @@ -0,0 +1,847 @@ +/** + * @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 type {Block} from '../block.js'; +import {BlockSvg} from '../block_svg.js'; +import * as common from '../common.js'; +import type {Connection} from '../connection.js'; +import {ConnectionType} from '../connection_type.js'; +import type {Abstract} from '../events/events_abstract.js'; +import {Click, ClickTarget} from '../events/events_click.js'; +import {EventType} from '../events/type.js'; +import * as eventUtils from '../events/utils.js'; +import type {Field} from '../field.js'; +import * as registry from '../registry.js'; +import type {MarkerSvg} from '../renderers/common/marker_svg.js'; +import type {PathObject} from '../renderers/zelos/path_object.js'; +import {Renderer} from '../renderers/zelos/renderer.js'; +import * as dom from '../utils/dom.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {ASTNode} from './ast_node.js'; +import {Marker} from './marker.js'; + +/** Options object for LineCursor instances. */ +export interface CursorOptions { + /** + * Can the cursor visit all stack connections (next/previous), or + * (if false) only unconnected next connections? + */ + stackConnections: boolean; +} + +/** Default options for LineCursor instances. */ +const defaultOptions: CursorOptions = { + stackConnections: true, +}; + +/** + * Class for a line cursor. + */ +export class LineCursor extends Marker { + override type = 'cursor'; + + /** Options for this line cursor. */ + private readonly options: CursorOptions; + + /** Locations to try moving the cursor to after a deletion. */ + private potentialNodes: ASTNode[] | null = null; + + /** Whether the renderer is zelos-style. */ + private isZelos = false; + + /** + * @param workspace The workspace this cursor belongs to. + * @param options Cursor options. + */ + constructor( + private readonly workspace: WorkspaceSvg, + options?: Partial, + ) { + super(); + // Bind changeListener to facilitate future disposal. + this.changeListener = this.changeListener.bind(this); + this.workspace.addChangeListener(this.changeListener); + // Regularise options and apply defaults. + this.options = {...defaultOptions, ...options}; + + this.isZelos = workspace.getRenderer() instanceof Renderer; + } + + /** + * Clean up this cursor. + */ + dispose() { + this.workspace.removeChangeListener(this.changeListener); + super.dispose(); + } + + /** + * 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(): ASTNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getNextNode( + curNode, + this.validLineNode.bind(this), + 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(): ASTNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getNextNode( + curNode, + this.validInLineNode.bind(this), + 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(): ASTNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getPreviousNode( + curNode, + this.validLineNode.bind(this), + 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(): ASTNode | null { + const curNode = this.getCurNode(); + if (!curNode) { + return null; + } + const newNode = this.getPreviousNode( + curNode, + this.validInLineNode.bind(this), + true, + ); + + if (newNode) { + this.setCurNode(newNode); + } + return newNode; + } + + /** + * Returns true iff the node to which we would navigate if in() were + * called, which will be a validInLineNode, is also a validLineNode + * - 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 rightNode = this.getNextNode( + curNode, + this.validInLineNode.bind(this), + false, + ); + return this.validLineNode(rightNode); + } + + /** + * Returns true iff the given node represents the "beginning of a + * new line of code" (and thus can be visited by pressing the + * up/down arrow keys). Specifically, if the node is for: + * + * - Any block that is not a value block. + * - A top-level value block (one that is unconnected). + * - An unconnected next statement input. + * - An unconnected 'next' connection - the "blank line at the end". + * This is to facilitate connecting additional blocks to a + * stack/substack. + * + * If options.stackConnections is true (the default) then allow the + * cursor to visit all (useful) stack connection by additionally + * returning true for: + * + * - Any next statement input + * - Any 'next' connection. + * - An unconnected previous statement input. + * + * @param node The AST node to check. + * @returns True if the node should be visited, false otherwise. + */ + protected validLineNode(node: ASTNode | null): boolean { + if (!node) return false; + const location = node.getLocation(); + const type = node && node.getType(); + switch (type) { + case ASTNode.types.BLOCK: + return !(location as Block).outputConnection?.isConnected(); + case ASTNode.types.INPUT: { + const connection = location as Connection; + return ( + connection.type === ConnectionType.NEXT_STATEMENT && + (this.options.stackConnections || !connection.isConnected()) + ); + } + case ASTNode.types.NEXT: + return ( + this.options.stackConnections || + !(location as Connection).isConnected() + ); + case ASTNode.types.PREVIOUS: + return ( + this.options.stackConnections && + !(location as Connection).isConnected() + ); + default: + return false; + } + } + + /** + * Returns true iff the given node can be visited by the cursor when + * using the left/right arrow keys. Specifically, if the node is + * any node for which valideLineNode would return true, plus: + * + * - Any block. + * - Any field that is not a full block field. + * - Any unconnected next or input connection. This is to + * facilitate connecting additional blocks. + * + * @param node The AST node to check whether it is valid. + * @returns True if the node should be visited, false otherwise. + */ + protected validInLineNode(node: ASTNode | null): boolean { + if (!node) return false; + if (this.validLineNode(node)) return true; + const location = node.getLocation(); + const type = node && node.getType(); + switch (type) { + case ASTNode.types.BLOCK: + return true; + case ASTNode.types.INPUT: + return !(location as Connection).isConnected(); + case ASTNode.types.FIELD: { + const field = node.getLocation() as Field; + return !( + field.getSourceBlock()?.isSimpleReporter() && field.isFullBlockField() + ); + } + default: + return false; + } + } + + /** + * Returns true iff the given node can be visited by the cursor. + * Specifically, if the node is any for which validInLineNode would + * return true, or if it is a workspace node. + * + * @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 { + return ( + !!node && + (this.validInLineNode(node) || node.getType() === ASTNode.types.WORKSPACE) + ); + } + + /** + * 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. + */ + private getNextNodeImpl( + node: ASTNode | null, + isValid: (p1: ASTNode | null) => boolean, + ): ASTNode | null { + if (!node) return null; + let newNode = node.in() || node.next(); + if (isValid(newNode)) return newNode; + if (newNode) return this.getNextNodeImpl(newNode, isValid); + + newNode = this.findSiblingOrParentSibling(node.out()); + if (isValid(newNode)) return newNode; + if (newNode) return this.getNextNodeImpl(newNode, isValid); + 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 + * novalid node was found. + * @returns The next node in the traversal. + */ + getNextNode( + node: ASTNode | null, + isValid: (p1: ASTNode | null) => boolean, + loop: boolean, + ): ASTNode | null { + if (!node) return null; + + const potential = this.getNextNodeImpl(node, isValid); + if (potential || !loop) return potential; + // Loop back. + const firstNode = this.getFirstNode(); + if (isValid(firstNode)) return firstNode; + return this.getNextNodeImpl(firstNode, 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. + * @returns The previous node in the traversal or null if no previous node + * exists. + */ + private getPreviousNodeImpl( + 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; + if (newNode) return this.getPreviousNodeImpl(newNode, isValid); + 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: ASTNode | null, + isValid: (p1: ASTNode | null) => boolean, + loop: boolean, + ): ASTNode | null { + if (!node) return null; + + const potential = this.getPreviousNodeImpl(node, isValid); + if (potential || !loop) return potential; + // Loop back. + const lastNode = this.getLastNode(); + if (isValid(lastNode)) return lastNode; + return this.getPreviousNodeImpl(lastNode, isValid); + } + + /** + * From the given node find either the next valid sibling or the parent's + * next sibling. + * + * @param node The current position in the AST. + * @returns The next sibling node, the parent's next sibling, or null. + */ + private findSiblingOrParentSibling(node: ASTNode | null): ASTNode | null { + if (!node) return null; + const nextNode = node.next(); + if (nextNode) return nextNode; + return this.findSiblingOrParentSibling(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): ASTNode | null { + let newNode = node.in(); + if (!newNode) return node; + for ( + let nextNode: ASTNode | null = newNode; + nextNode; + nextNode = newNode.next() + ) { + newNode = nextNode; + } + return this.getRightMostChild(newNode); + } + + /** + * 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: Block) { + const curNode = this.getCurNode(); + + const nodes: ASTNode[] = curNode ? [curNode] : []; + // The connection to which the deleted block is attached. + const parentConnection = + deletedBlock.previousConnection?.targetConnection ?? + deletedBlock.outputConnection?.targetConnection; + if (parentConnection) { + const parentNode = ASTNode.createConnectionNode(parentConnection); + if (parentNode) nodes.push(parentNode); + } + // The block connected to the next connection of the deleted block. + const nextBlock = deletedBlock.getNextBlock(); + if (nextBlock) { + const nextNode = ASTNode.createBlockNode(nextBlock); + if (nextNode) nodes.push(nextNode); + } + // The parent block of the deleted block. + const parentBlock = deletedBlock.getParent(); + if (parentBlock) { + const parentNode = ASTNode.createBlockNode(parentBlock); + if (parentNode) nodes.push(parentNode); + } + // A location on the workspace beneath the deleted block. + // Move to the workspace. + const curBlock = curNode?.getSourceBlock(); + if (curBlock) { + const workspaceNode = ASTNode.createWorkspaceNode( + this.workspace, + curBlock.getRelativeToSurfaceXY(), + ); + if (workspaceNode) nodes.push(workspaceNode); + } + 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.validNode(node) && !node.getSourceBlock()?.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(): ASTNode | null { + this.updateCurNodeFromSelection(); + return super.getCurNode(); + } + + /** + * Sets the object in charge of drawing the marker. + * + * We want to customize drawing, so rather than directly setting the given + * object, we instead set a wrapper proxy object that passes through all + * method calls and property accesses except for draw(), which it delegates + * to the drawMarker() method in this class. + * + * @param drawer The object ~in charge of drawing the marker. + */ + override setDrawer(drawer: MarkerSvg) { + const altDraw = function ( + this: LineCursor, + oldNode: ASTNode | null, + curNode: ASTNode | null, + ) { + // Pass the unproxied, raw drawer object so that drawMarker can call its + // `draw()` method without triggering infinite recursion. + this.drawMarker(oldNode, curNode, drawer); + }.bind(this); + + super.setDrawer( + new Proxy(drawer, { + get(target: typeof drawer, prop: keyof typeof drawer) { + if (prop === 'draw') { + return altDraw; + } + + return target[prop]; + }, + }), + ); + } + + /** + * 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. + * @param updateSelection If true (the default) we'll update the selection + * too. + */ + override setCurNode(newNode: ASTNode | null, updateSelection = true) { + if (updateSelection) { + this.updateSelectionFromNode(newNode); + } + + super.setCurNode(newNode); + + // Try to scroll cursor into view. + if (newNode?.getType() === ASTNode.types.BLOCK) { + const block = newNode.getLocation() as BlockSvg; + block.workspace.scrollBoundsIntoView( + block.getBoundingRectangleWithoutChildren(), + ); + } + } + + /** + * Draw this cursor's marker. + * + * This is a wrapper around this.drawer.draw (usually implemented by + * MarkerSvg.prototype.draw) that will, if newNode is a BLOCK node, + * instead call `setSelected` to select it (if it's a regular block) + * or `addSelect` (if it's a shadow block, since shadow blocks can't + * be selected) instead of using the normal drawer logic. + * + * TODO(#142): The selection and fake-selection code was originally + * a hack added for testing on October 28 2024, because the default + * drawer (MarkerSvg) behaviour in Zelos was to draw a box around + * the block and all attached child blocks, which was confusing when + * navigating stacks. + * + * Since then we have decided that we probably _do_ in most cases + * want navigating to a block to select the block, but more + * particularly that we want navigation to move _focus_. Replace + * this selection hack with non-hacky changing of focus once that's + * possible. + * + * @param oldNode The previous node. + * @param curNode The current node. + * @param realDrawer The object ~in charge of drawing the marker. + */ + private drawMarker( + oldNode: ASTNode | null, + curNode: ASTNode | null, + realDrawer: MarkerSvg, + ) { + // If old node was a block, unselect it or remove fake selection. + if (oldNode?.getType() === ASTNode.types.BLOCK) { + const block = oldNode.getLocation() as BlockSvg; + if (!block.isShadow()) { + // Selection should already be in sync. + } else { + block.removeSelect(); + } + } + + if (this.isZelos && oldNode && this.isValueInputConnection(oldNode)) { + this.hideAtInput(oldNode); + } + + const curNodeType = curNode?.getType(); + const isZelosInputConnection = + this.isZelos && curNode && this.isValueInputConnection(curNode); + + // If drawing can't be handled locally, just use the drawer. + if (curNodeType !== ASTNode.types.BLOCK && !isZelosInputConnection) { + realDrawer.draw(oldNode, curNode); + return; + } + + // Hide any visible marker SVG and instead do some manual rendering. + realDrawer.hide(); + + if (isZelosInputConnection) { + this.showAtInput(curNode); + } else if (curNode && curNodeType === ASTNode.types.BLOCK) { + const block = curNode.getLocation() as BlockSvg; + if (!block.isShadow()) { + // Selection should already be in sync. + } else { + block.addSelect(); + block.getParent()?.removeSelect(); + } + } + + // Call MarkerSvg.prototype.fireMarkerEvent like + // MarkerSvg.prototype.draw would (even though it's private). + (realDrawer as any)?.fireMarkerEvent?.(oldNode, curNode); + } + + /** + * Check whether the node represents a value input connection. + * + * @param node The node to check + * @returns True if the node represents a value input connection. + */ + private isValueInputConnection(node: ASTNode) { + if (node?.getType() !== ASTNode.types.INPUT) return false; + const connection = node.getLocation() as Connection; + return connection.type === ConnectionType.INPUT_VALUE; + } + + /** + * Hide the cursor rendering at the given input node. + * + * @param node The input node to hide. + */ + private hideAtInput(node: ASTNode) { + const inputConnection = node.getLocation() as Connection; + const sourceBlock = inputConnection.getSourceBlock() as BlockSvg; + const input = inputConnection.getParentInput(); + if (input) { + const pathObject = sourceBlock.pathObject as PathObject; + const outlinePath = pathObject.getOutlinePath(input.name); + dom.removeClass(outlinePath, 'inputActiveFocus'); + } + } + + /** + * Show the cursor rendering at the given input node. + * + * @param node The input node to show. + */ + private showAtInput(node: ASTNode) { + const inputConnection = node.getLocation() as Connection; + const sourceBlock = inputConnection.getSourceBlock() as BlockSvg; + const input = inputConnection.getParentInput(); + if (input) { + const pathObject = sourceBlock.pathObject as PathObject; + const outlinePath = pathObject.getOutlinePath(input.name); + dom.addClass(outlinePath, 'inputActiveFocus'); + } + } + + /** + * Event listener that syncs the cursor location to the selected block on + * SELECTED events. + * + * This does not run early enough in all cases so `getCurNode()` also updates + * the node from the selection. + * + * @param event The `Selected` event. + */ + private changeListener(event: Abstract) { + switch (event.type) { + case EventType.SELECTED: + this.updateCurNodeFromSelection(); + break; + case EventType.CLICK: { + const click = event as Click; + if ( + click.workspaceId === this.workspace.id && + click.targetType === ClickTarget.WORKSPACE + ) { + this.setCurNode(null); + } + } + } + } + + /** + * Updates the current node to match the selection. + * + * Clears the current node if it's on a block but the selection is null. + * Sets the node to a block if selected for our workspace. + * For shadow blocks selections the parent is used by default (unless we're + * already on the shadow block via keyboard) as that's where the visual + * selection is. + */ + private updateCurNodeFromSelection() { + const curNode = super.getCurNode(); + const selected = common.getSelected(); + + if (selected === null && curNode?.getType() === ASTNode.types.BLOCK) { + this.setCurNode(null, false); + return; + } + if (selected?.workspace !== this.workspace) { + return; + } + if (selected instanceof BlockSvg) { + let block: BlockSvg | null = selected; + if (selected.isShadow()) { + // OK if the current node is on the parent OR the shadow block. + // The former happens for clicks, the latter for keyboard nav. + if ( + curNode && + (curNode.getLocation() === block || + curNode.getLocation() === block.getParent()) + ) { + return; + } + block = block.getParent(); + } + if (block) { + this.setCurNode(ASTNode.createBlockNode(block), false); + } + } + } + + /** + * Updates the selection from the node. + * + * Clears the selection for non-block nodes. + * Clears the selection for shadow blocks as the selection is drawn on + * the parent but the cursor will be drawn on the shadow block itself. + * We need to take care not to later clear the current node due to that null + * selection, so we track the latest selection we're in sync with. + * + * @param newNode The new node. + */ + private updateSelectionFromNode(newNode: ASTNode | null) { + if (newNode?.getType() === ASTNode.types.BLOCK) { + if (common.getSelected() !== newNode.getLocation()) { + eventUtils.disable(); + common.setSelected(newNode.getLocation() as BlockSvg); + eventUtils.enable(); + } + } else { + if (common.getSelected()) { + eventUtils.disable(); + common.setSelected(null); + eventUtils.enable(); + } + } + } + + /** + * Get the first navigable node on the workspace, or null if none exist. + * + * @returns The first navigable node on the workspace, or null. + */ + getFirstNode(): ASTNode | null { + const topBlocks = this.workspace.getTopBlocks(true); + if (!topBlocks.length) return null; + return ASTNode.createTopNode(topBlocks[0]); + } + + /** + * Get the last navigable node on the workspace, or null if none exist. + * + * @returns The last navigable node on the workspace, or null. + */ + getLastNode(): ASTNode | null { + // Loop back to last block if it exists. + const topBlocks = this.workspace.getTopBlocks(true); + if (!topBlocks.length) return null; + + // Find the last stack. + const lastTopBlockNode = ASTNode.createStackNode( + topBlocks[topBlocks.length - 1], + ); + let prevNode = lastTopBlockNode; + let nextNode: ASTNode | null = lastTopBlockNode; + // Iterate until you fall off the end of the stack. + while (nextNode) { + prevNode = nextNode; + nextNode = this.getNextNode( + prevNode, + (node) => { + return !!node; + }, + false, + ); + } + return prevNode; + } +} + +registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/core/keyboard_nav/marker.ts b/core/keyboard_nav/marker.ts index e3b438e6efe..aaa8e7355db 100644 --- a/core/keyboard_nav/marker.ts +++ b/core/keyboard_nav/marker.ts @@ -24,24 +24,17 @@ 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; + private curNode: ASTNode | null = null; /** * 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; + private drawer: MarkerSvg | null = null; /** The type of the marker. */ type = 'marker'; - /** Constructs a new Marker instance. */ - constructor() {} - /** * Sets the object in charge of drawing the marker. * @@ -56,7 +49,7 @@ export class Marker { * * @returns The object in charge of drawing the marker. */ - getDrawer(): MarkerSvg { + getDrawer(): MarkerSvg | null { return this.drawer; } @@ -65,23 +58,19 @@ export class Marker { * * @returns The current field, connection, or block the marker is on. */ - getCurNode(): ASTNode { + getCurNode(): ASTNode | 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) { + setCurNode(newNode: ASTNode | null) { const oldNode = this.curNode; this.curNode = newNode; - if (this.drawer) { - this.drawer.draw(oldNode, this.curNode); - } + this.drawer?.draw(oldNode, this.curNode); } /** @@ -90,22 +79,18 @@ export class Marker { * @internal */ draw() { - if (this.drawer) { - this.drawer.draw(this.curNode, this.curNode); - } + this.drawer?.draw(this.curNode, this.curNode); } /** Hide the marker SVG. */ hide() { - if (this.drawer) { - this.drawer.hide(); - } + this.drawer?.hide(); } /** Dispose of this marker. */ dispose() { - if (this.getDrawer()) { - this.getDrawer().dispose(); - } + this.drawer?.dispose(); + this.drawer = null; + this.curNode = null; } } 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/label_flyout_inflater.ts b/core/label_flyout_inflater.ts new file mode 100644 index 00000000000..e4f3e3b54db --- /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, true); + } + + /** + * 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/marker_manager.ts b/core/marker_manager.ts index d7035534da7..a8d1d20c2bd 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,7 +23,7 @@ export class MarkerManager { static readonly LOCAL_MARKER = 'local_marker_1'; /** The cursor. */ - private cursor: Cursor | null = null; + private cursor: LineCursor | null = null; /** The cursor's SVG element. */ private cursorSvg: SVGElement | null = null; @@ -50,10 +50,11 @@ 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()); + const drawer = this.workspace + .getRenderer() + .makeMarkerDrawer(this.workspace, marker); + marker.setDrawer(drawer); + this.setMarkerSvg(drawer.createDom()); this.markers.set(id, marker); } @@ -82,7 +83,7 @@ export class MarkerManager { * * @returns The cursor for this workspace. */ - getCursor(): Cursor | null { + getCursor(): LineCursor | null { return this.cursor; } @@ -103,17 +104,15 @@ 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?.getDrawer()?.dispose(); 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()); + this.setCursorSvg(drawer.createDom()); } } 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/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..168e59744d2 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -18,10 +18,14 @@ 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 {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'; /** Maximum randomness in workspace units for bumping a block. */ const BUMP_RANDOMNESS = 10; @@ -29,7 +33,7 @@ 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 { // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. sourceBlock_!: BlockSvg; private readonly db: ConnectionDB; @@ -588,6 +592,40 @@ 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); + } } export namespace RenderedConnection { 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..09320710c51 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, diff --git a/core/renderers/common/i_path_object.ts b/core/renderers/common/i_path_object.ts index 3a78035e156..699f1d92edb 100644 --- a/core/renderers/common/i_path_object.ts +++ b/core/renderers/common/i_path_object.ts @@ -49,21 +49,6 @@ 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. */ @@ -130,8 +115,23 @@ export interface IPathObject { rtl: boolean, ): 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; + /** * 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 index 057324f0346..4805e70400a 100644 --- a/core/renderers/common/marker_svg.ts +++ b/core/renderers/common/marker_svg.ts @@ -156,7 +156,7 @@ export class MarkerSvg { * @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) { + draw(oldNode: ASTNode | null, curNode: ASTNode | null) { if (!curNode) { this.hide(); return; @@ -287,8 +287,8 @@ export class MarkerSvg { */ protected showWithCoordinates_(curNode: ASTNode) { const wsCoordinate = curNode.getWsCoordinate(); - let x = wsCoordinate.x; - const y = wsCoordinate.y; + let x = wsCoordinate?.x ?? 0; + const y = wsCoordinate?.y ?? 0; if (this.workspace.RTL) { x -= this.constants_.CURSOR_WS_WIDTH; @@ -620,7 +620,7 @@ export class MarkerSvg { * @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) { + protected fireMarkerEvent(oldNode: ASTNode | null, curNode: ASTNode) { const curBlock = curNode.getSourceBlock(); const event = new (eventUtils.get(EventType.MARKER_MOVE))( curBlock, diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index 0f46cf3a423..077f80bb741 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -65,6 +65,8 @@ export class PathObject implements IPathObject { {'class': 'blocklyPath'}, this.svgRoot, ); + + this.setClass_('blocklyBlock', true); } /** @@ -167,14 +169,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 +185,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 +200,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); } /** diff --git a/core/renderers/common/renderer.ts b/core/renderers/common/renderer.ts index d3bff56a702..812ddd97678 100644 --- a/core/renderers/common/renderer.ts +++ b/core/renderers/common/renderer.ts @@ -10,15 +10,9 @@ 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'; @@ -79,17 +73,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 +102,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 +123,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); } /** @@ -223,49 +236,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..5cc52c0cbb2 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 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/path_object.ts b/core/renderers/zelos/path_object.ts index a46d355b674..f40426483a7 100644 --- a/core/renderers/zelos/path_object.ts +++ b/core/renderers/zelos/path_object.ts @@ -90,11 +90,7 @@ 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'); this.svgRoot.appendChild(this.svgPathSelected); } } else { @@ -107,14 +103,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 +161,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..c880ce9f80b 100644 --- a/core/renderers/zelos/renderer.ts +++ b/core/renderers/zelos/renderer.ts @@ -7,12 +7,8 @@ // 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'; @@ -107,36 +103,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/separator_flyout_inflater.ts b/core/separator_flyout_inflater.ts new file mode 100644 index 00000000000..63e53355478 --- /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, false); + } + + /** + * 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..0793e6213b4 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -40,7 +40,7 @@ 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 @@ -62,7 +62,7 @@ export function registerDelete() { preconditionFn(workspace) { const selected = common.getSelected(); return ( - !workspace.options.readOnly && + !workspace.isReadOnly() && selected != null && isDeletable(selected) && selected.isDeletable() && @@ -113,7 +113,7 @@ export function registerCopy() { preconditionFn(workspace) { const selected = common.getSelected(); return ( - !workspace.options.readOnly && + !workspace.isReadOnly() && !Gesture.inProgress() && selected != null && isDeletable(selected) && @@ -164,7 +164,7 @@ export function registerCut() { preconditionFn(workspace) { const selected = common.getSelected(); return ( - !workspace.options.readOnly && + !workspace.isReadOnly() && !Gesture.inProgress() && selected != null && isDeletable(selected) && @@ -221,7 +221,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; @@ -269,7 +269,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. @@ -308,7 +308,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. 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..d8ee8736ea6 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', }; } @@ -662,19 +662,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 +682,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 +695,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..31ccb7e42f3 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -87,7 +87,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 12037839399..b0fd82e97f2 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -70,9 +70,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; @@ -82,7 +79,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. */ @@ -118,9 +118,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; } @@ -143,7 +140,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); @@ -196,7 +195,7 @@ export class Toolbox protected createContainer_(): HTMLDivElement { const toolboxContainer = document.createElement('div'); toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v'); - dom.addClass(toolboxContainer, 'blocklyToolboxDiv'); + dom.addClass(toolboxContainer, 'blocklyToolbox'); toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR'); return toolboxContainer; } @@ -208,7 +207,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'; } @@ -365,14 +364,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(); @@ -443,8 +436,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(); @@ -461,7 +453,7 @@ export class Toolbox * @returns The list of items in the toolbox. */ getToolboxItems(): IToolboxItem[] { - return this.contents_; + return [...this.contents.values()]; } /** @@ -616,7 +608,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; } /** @@ -732,13 +724,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); @@ -758,14 +755,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(); } - } + }); } /** @@ -916,11 +912,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); } } @@ -1027,11 +1021,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); @@ -1051,11 +1046,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); @@ -1069,16 +1065,13 @@ 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); @@ -1100,7 +1093,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; @@ -1110,13 +1106,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/trashcan.ts b/core/trashcan.ts index 05ae9fbf270..5e0a218971a 100644 --- a/core/trashcan.ts +++ b/core/trashcan.ts @@ -239,10 +239,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..87019dbb2de 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) { diff --git a/core/utils/focusable_tree_traverser.ts b/core/utils/focusable_tree_traverser.ts new file mode 100644 index 00000000000..94603edd01b --- /dev/null +++ b/core/utils/focusable_tree_traverser.ts @@ -0,0 +1,119 @@ +/** + * @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 root = tree.getRootFocusableNode().getFocusableElement(); + if ( + dom.hasClass(root, FocusableTreeTraverser.ACTIVE_CLASS_NAME) || + dom.hasClass(root, FocusableTreeTraverser.PASSIVE_CSS_CLASS_NAME) + ) { + // The root has focus. + return tree.getRootFocusableNode(); + } + + 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. + if (element === tree.getRootFocusableNode().getFocusableElement()) { + return tree.getRootFocusableNode(); + } + + // 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/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..40efc3ea6e5 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 * as Variables from './variables.js'; import type {Workspace} from './workspace.js'; /** @@ -32,13 +33,18 @@ 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) {} @@ -46,8 +52,8 @@ export class VariableMap { /** 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,11 +67,14 @@ 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(); @@ -83,16 +92,41 @@ export class VariableMap { } finally { 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 +144,14 @@ 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; + variable.setName(newName); for (let i = 0; i < blocks.length; i++) { blocks[i].updateVarName(variable); } @@ -135,13 +169,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. @@ -155,8 +189,8 @@ export class VariableMap { } // 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); + // And remove it from the map. + this.variableMap.get(type)?.delete(variable.getId()); } /* End functions for renaming variables. */ @@ -173,9 +207,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,100 +232,51 @@ 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); - + const variables = + this.variableMap.get(type) ?? + new Map>(); + variables.set(variable.getId(), variable); + if (!this.variableMap.has(type)) { + this.variableMap.set(type, variables); + } eventUtils.fire(new (eventUtils.get(EventType.VAR_CREATE))(variable)); - return variable; } - /* Begin functions for variable deletion. */ /** - * Delete a variable. + * Adds the given variable to this variable map. * - * @param variable Variable to delete. + * @param variable The variable to add. */ - 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; - } - } - } - } - - /** - * Delete a variables by the passed in ID and all of its uses from this - * workspace. May prompt the user for confirmation. - * - * @param id ID of variable to delete. - */ - deleteVariableById(id: string) { - 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); + 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. */ /** - * Deletes a variable and all of its uses from this workspace without asking - * the user for confirmation. + * Delete a variable and all of its uses without confirmation. * * @param variable Variable to delete. - * @param uses An array of uses of the variable. - * @internal */ - deleteVariableInternal(variable: VariableModel, uses: Block[]) { + deleteVariable(variable: IVariableModel) { + const uses = this.getVariableUsesById(variable.getId()); const existingGroup = eventUtils.getGroup(); if (!existingGroup) { eventUtils.setGroup(true); @@ -300,11 +285,38 @@ export class VariableMap { for (let i = 0; i < uses.length; i++) { uses[i].dispose(true); } - this.deleteVariable(variable); + const variables = this.variableMap.get(variable.getType()); + if (!variables || !variables.has(variable.getId())) return; + variables.delete(variable.getId()); + eventUtils.fire(new (eventUtils.get(EventType.VAR_DELETE))(variable)); + if (variables.size === 0) { + this.variableMap.delete(variable.getType()); + } } finally { eventUtils.setGroup(existingGroup); } } + + /** + * 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) { + Variables.deleteVariable(this.workspace, variable); + } + } + /* End functions for variable deletion. */ /** * Find the variable by the given name and type and return it. Return null if @@ -315,17 +327,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 +348,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 +365,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 +387,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 +398,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 Variables.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..c896efd0f1a 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'; @@ -34,9 +37,11 @@ export const CATEGORY_NAME = 'VARIABLE'; * @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(); @@ -81,6 +86,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.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 +244,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}'); @@ -142,7 +298,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'); @@ -265,11 +421,13 @@ export function createVariableButtonHandler( } 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 +450,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,9 +466,13 @@ 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, ); @@ -324,8 +486,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 +541,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 +566,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 +617,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 +657,7 @@ function checkForConflictingParamWithLegacyProcedures( * @returns The generated DOM. */ export function generateVariableFieldDom( - variableModel: VariableModel, + variableModel: IVariableModel, ): Element { /* Generates the following XML: * foo @@ -500,8 +665,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 +688,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 +716,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 +761,7 @@ function createVariable( id: string | null, opt_name?: string, opt_type?: string, -): VariableModel { +): IVariableModel { const potentialVariableMap = workspace.getPotentialVariableMap(); // Variables without names get uniquely named for this workspace. if (!opt_name) { @@ -609,7 +774,11 @@ 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); @@ -632,8 +801,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 +818,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..4e2682ce8e9 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.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']); @@ -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..f167b6cf04d 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -166,10 +166,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..261da0f2475 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -24,15 +24,20 @@ 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 * as Variables from './variables.js'; /** * Class for a workspace. This is a data structure that contains blocks. @@ -107,8 +112,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 +124,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 +152,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 +368,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 +386,28 @@ 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. + * @deprecated v12 - Rename a variable by updating its name in the variable + * map. Identify the variable to rename with the given ID. * * @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 Variables.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; + } + Variables.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,6 +867,7 @@ export class Workspace implements IASTNodeLocation { * @internal */ createPotentialVariableMap() { + const VariableMap = this.getVariableMapClass(); this.potentialVariableMap = new VariableMap(this); } @@ -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_svg.ts b/core/workspace_svg.ts index 6acd31c9c7f..91668b744d4 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -35,16 +35,22 @@ import { import * as dropDownDiv from './dropdowndiv.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 {Gesture} from './gesture.js'; import {Grid} from './grid.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 {IDragTarget} from './interfaces/i_drag_target.js'; import type {IFlyout} from './interfaces/i_flyout.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 { + IVariableModel, + IVariableState, +} from './interfaces/i_variable_model.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'; @@ -71,7 +77,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 +91,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 IASTNodeLocationSvg, IContextMenu +{ /** * A wrapper function called when a resize event occurs. * You can pass the result to `eventHandling.unbind`. @@ -222,7 +230,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. @@ -361,24 +369,24 @@ 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); } @@ -483,7 +491,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 +544,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 +644,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 +699,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 +707,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 +751,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; } @@ -760,8 +777,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 +832,14 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { this.options, ); - if (CursorClass) this.markerManager.setCursor(new CursorClass()); + if (CursorClass) this.markerManager.setCursor(new CursorClass(this)); - this.renderer.createDom(this.svgGroup_, this.getTheme()); + const isParentWorkspace = this.options.parentWorkspace === null; + this.renderer.createDom( + this.svgGroup_, + this.getTheme(), + isParentWorkspace ? this.getInjectionDiv() : undefined, + ); return this.svgGroup_; } @@ -1045,8 +1066,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(); } @@ -1086,6 +1106,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { /** * @returns The layer manager for this workspace. + * @internal */ getLayerManager(): LayerManager | null { return this.layerManager; @@ -1351,7 +1372,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { name: string, opt_type?: string | null, opt_id?: string | null, - ): VariableModel { + ): IVariableModel { const newVar = super.createVariable(name, opt_type, opt_id); this.refreshToolboxSelection(); return newVar; @@ -1685,13 +1706,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. @@ -1699,7 +1720,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); } /** @@ -2033,18 +2062,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 @@ -2442,6 +2522,102 @@ 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 = viewport.left - bounds.left; + } else if (bounds.right > viewport.right) { + deltaX = viewport.right - bounds.right; + } + + if (bounds.top < viewport.top) { + deltaY = viewport.top - bounds.top; + } else if (bounds.bottom > viewport.bottom) { + deltaY = viewport.bottom - bounds.bottom; + } + + deltaX *= scale; + deltaY *= scale; + this.scroll(this.scrollX + deltaX, this.scrollY + deltaY); + } } /** diff --git a/core/xml.ts b/core/xml.ts index cecc4dce20e..f4b5f66ddd2 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); } @@ -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 fcd8897bd04..c4de18656a0 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -24,6 +24,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.", @@ -402,5 +403,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 68859c47385..c3f6b037677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly", - "version": "11.2.1", + "version": "12.0.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly", - "version": "11.2.1", + "version": "12.0.0-beta.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 286e0439704..4cb3f225b66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "11.2.1", + "version": "12.0.0-beta.4", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" diff --git a/tests/mocha/block_test.js b/tests/mocha/block_test.js index a489fb3e3c5..56cfba54290 100644 --- a/tests/mocha/block_test.js +++ b/tests/mocha/block_test.js @@ -1421,6 +1421,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..53f0714da11 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -12,123 +12,835 @@ import { } 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 = 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); + }); + + test('Next - From a Previous connection go to the next 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.A); + }); + test('Next - From a block go to its statement input', function () { + const prevNode = ASTNode.createBlockNode(this.blocks.B); + this.cursor.setCurNode(prevNode); + this.cursor.next(); + const curNode = this.cursor.getCurNode(); + assert.equal( + curNode.getLocation(), + this.blocks.B.getInput('NAME4').connection, + ); + }); + + 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); + }); + + test('Prev - From previous connection does not 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.nextConnection); + }); + + test('Prev - From first connection loop to last next connection', function () { + const prevConnection = this.blocks.A.previousConnection; + const prevConnectionNode = ASTNode.createConnectionNode(prevConnection); + this.cursor.setCurNode(prevConnectionNode); + this.cursor.prev(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode.getLocation(), this.blocks.D.nextConnection); + }); + + test('Out - From field does not 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); + }); + + test('Out - From first connection loop to last next connection', function () { + const prevConnection = this.blocks.A.previousConnection; + const prevConnectionNode = ASTNode.createConnectionNode(prevConnection); + this.cursor.setCurNode(prevConnectionNode); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode.getLocation(), 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.getLocation(), this.blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node.getLocation(), 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.getLocation(), this.blockA.previousConnection); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node.getLocation(), this.blockA.nextConnection); + }); + }); + + 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.getLocation(), this.blockA.outputConnection); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node.getLocation(), this.blockA.inputList[0].connection); + }); + }); + 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.getLocation(), this.blockA); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + assert.equal(node.getLocation(), this.blockA.inputList[0].connection); + }); + }); + + 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.getLocation(), blockA.previousConnection); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + const blockB = this.workspace.getBlockById('B'); + assert.equal(node.getLocation(), blockB.nextConnection); + }); + }); + + 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.getLocation(), blockA.outputConnection); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + const blockB = this.workspace.getBlockById('B'); + assert.equal(node.getLocation(), blockB.inputList[0].connection); + }); + }); + 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.getLocation(); + const previousConnection = + this.workspace.getBlockById('A').previousConnection; + assert.equal(location, previousConnection); + }); + test('getLastNode', function () { + const node = this.cursor.getLastNode(); + const location = node.getLocation(); + const nextConnection = this.workspace.getBlockById('D').nextConnection; + assert.equal(location, nextConnection); + }); + }); }); + 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.isConnection = (node) => { + return node && node.isConnection(); + }; + }); + 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 = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + 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 = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(nextNode.getLocation(), this.blockA); + }); + test('Always valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(nextNode.getLocation(), this.blockB.getField('FIELD')); + }); + test('Always valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + 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 connection - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + false, + ); + assert.equal(nextNode.getLocation(), this.blockA.nextConnection); + }); + test('Valid if connection - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + false, + ); + assert.equal(nextNode.getLocation(), this.blockB.nextConnection); + }); + test('Valid if connection - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start at end - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + 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 = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + true, + ); + assert.equal(nextNode.getLocation(), 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 connection - start at end - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + true, + ); + assert.equal(nextNode.getLocation(), this.blockA.previousConnection); + }); + }); }); - 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.isConnection = (node) => { + return node && node.isConnection(); + }; + }); + 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 = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + test('Never valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + test('Never valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + + test('Always valid - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.isNotNull(previousNode); + }); + test('Always valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal( + previousNode.getLocation(), + this.blockB.previousConnection, + ); + }); + test('Always valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(previousNode.getLocation(), this.blockC.getField('FIELD')); + }); + + test('Valid if connection - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + false, + ); + assert.isNull(previousNode); + }); + test('Valid if connection - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + false, + ); + assert.equal( + previousNode.getLocation(), + this.blockB.previousConnection, + ); + }); + test('Valid if connection - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + false, + ); + assert.equal( + previousNode.getLocation(), + this.blockC.previousConnection, + ); + }); + test('Never valid - start at top - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + 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 = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + true, + ); + // Previous node will be a stack node in this case. + assert.equal(previousNode.getLocation(), this.blockA); + }); + test('Valid if connection - start at top - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + true, + ); + assert.equal(previousNode.getLocation(), this.blockC.nextConnection); + }); + }); }); }); 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_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 61deaf47f39..2ed7098fc9f 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..69ecfe722a5 --- /dev/null +++ b/tests/mocha/focus_manager_test.js @@ -0,0 +1,5122 @@ +/** + * @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() {} +} + +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('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}`; + + setup(function () { + sharedTestSetup.call(this); + + const testState = this; + const addDocumentEventListener = function (type, listener) { + testState.globalDocumentEventListenerType = type; + testState.globalDocumentEventListener = listener; + document.addEventListener(type, listener); + }; + this.focusManager = new FocusManager(addDocumentEventListener); + + 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', + ); + + 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); + + // Remove the globally registered listener from FocusManager to avoid state being shared across + // test boundaries. + const eventType = this.globalDocumentEventListenerType; + const eventListener = this.globalDocumentEventListener; + document.removeEventListener(eventType, eventListener); + + // 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); + }); + }); + + 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, + ); + }); + }); + 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, + ); + }); + }); + 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..d2467b6e95c --- /dev/null +++ b/tests/mocha/focusable_tree_traverser_test.js @@ -0,0 +1,524 @@ +/** + * @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() {} +} + +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..1c9f1fbbc6a 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) + + + + + @@ -51,6 +192,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'; @@ -76,6 +218,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,12 +232,13 @@ 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'; @@ -117,6 +261,7 @@ 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/keydown_test.js b/tests/mocha/keydown_test.js index 0b72a7fee6b..82293f22440 100644 --- a/tests/mocha/keydown_test.js +++ b/tests/mocha/keydown_test.js @@ -42,7 +42,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); }); 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..10bfd335223 100644 --- a/tests/mocha/toolbox_test.js +++ b/tests/mocha/toolbox_test.js @@ -47,7 +47,7 @@ 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(); @@ -98,7 +98,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 +153,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 +176,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 +198,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 +356,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 +389,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 +407,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 +732,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 +748,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/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/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 } }