release: v12.0.0

This commit is contained in:
Aaron Dodson
2025-05-15 13:17:20 -07:00
committed by GitHub
264 changed files with 18259 additions and 8627 deletions

View File

@@ -16,8 +16,7 @@ jobs:
requested-reviewer:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Assign requested reviewer
uses: actions/github-script@v7

View File

@@ -8,7 +8,7 @@ jobs:
label:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: bcoe/conventional-release-labels@v1

View File

@@ -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"
}
},

View File

@@ -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',

View File

@@ -9,7 +9,6 @@
import type {Block} from '../core/block.js';
import type {BlockSvg} from '../core/block_svg.js';
import type {BlockDefinition} from '../core/blocks.js';
import * as common from '../core/common.js';
import {defineBlocks} from '../core/common.js';
import {config} from '../core/config.js';
import type {Connection} from '../core/connection.js';
@@ -27,15 +26,19 @@ import {FieldCheckbox} from '../core/field_checkbox.js';
import {FieldLabel} from '../core/field_label.js';
import * as fieldRegistry from '../core/field_registry.js';
import {FieldTextInput} from '../core/field_textinput.js';
import {getFocusManager} from '../core/focus_manager.js';
import '../core/icons/comment_icon.js';
import {MutatorIcon as Mutator} from '../core/icons/mutator_icon.js';
import '../core/icons/warning_icon.js';
import {Align} from '../core/inputs/align.js';
import type {
IVariableModel,
IVariableState,
} from '../core/interfaces/i_variable_model.js';
import {Msg} from '../core/msg.js';
import {Names} from '../core/names.js';
import * as Procedures from '../core/procedures.js';
import * as xmlUtils from '../core/utils/xml.js';
import type {VariableModel} from '../core/variable_model.js';
import * as Variables from '../core/variables.js';
import type {Workspace} from '../core/workspace.js';
import type {WorkspaceSvg} from '../core/workspace_svg.js';
@@ -48,7 +51,7 @@ export const blocks: {[key: string]: BlockDefinition} = {};
type ProcedureBlock = Block & ProcedureMixin;
interface ProcedureMixin extends ProcedureMixinType {
arguments_: string[];
argumentVarModels_: VariableModel[];
argumentVarModels_: IVariableModel<IVariableState>[];
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<IVariableState>[] {
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<IVariableState>,
) {
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<IVariableState>;
/**
* 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<IVariableState>[];
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<IVariableState>[] {
return this.argumentVarModels_;
},
/**
@@ -1177,7 +1178,7 @@ const PROCEDURE_CALL_COMMON = {
const def = Procedures.getDefinition(name, workspace);
if (def) {
(workspace as WorkspaceSvg).centerOnBlock(def.id);
common.setSelected(def as BlockSvg);
getFocusManager().focusNode(def as BlockSvg);
}
},
});

View File

@@ -21,7 +21,6 @@ import '../core/field_label.js';
import {FieldVariable} from '../core/field_variable.js';
import {Msg} from '../core/msg.js';
import * as Variables from '../core/variables.js';
import type {WorkspaceSvg} from '../core/workspace_svg.js';
/**
* A dictionary of the block definitions provided by this module.
@@ -165,11 +164,11 @@ const deleteOptionCallbackFactory = function (
block: VariableBlock,
): () => void {
return function () {
const workspace = block.workspace;
const variableField = block.getField('VAR') as FieldVariable;
const variable = variableField.getVariable()!;
workspace.deleteVariableById(variable.getId());
(workspace as WorkspaceSvg).refreshToolboxSelection();
const variable = variableField.getVariable();
if (variable) {
Variables.deleteVariable(variable.getWorkspace(), variable, block);
}
};
};

View File

@@ -22,7 +22,6 @@ import '../core/field_label.js';
import {FieldVariable} from '../core/field_variable.js';
import {Msg} from '../core/msg.js';
import * as Variables from '../core/variables.js';
import type {WorkspaceSvg} from '../core/workspace_svg.js';
/**
* A dictionary of the block definitions provided by this module.
@@ -144,9 +143,9 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
const id = this.getFieldValue('VAR');
const variableModel = Variables.getVariable(this.workspace, id)!;
if (this.type === 'variables_get_dynamic') {
this.outputConnection!.setCheck(variableModel.type);
this.outputConnection!.setCheck(variableModel.getType());
} else {
this.getInput('VALUE')!.connection!.setCheck(variableModel.type);
this.getInput('VALUE')!.connection!.setCheck(variableModel.getType());
}
},
};
@@ -176,11 +175,11 @@ const renameOptionCallbackFactory = function (block: VariableBlock) {
*/
const deleteOptionCallbackFactory = function (block: VariableBlock) {
return function () {
const workspace = block.workspace;
const variableField = block.getField('VAR') as FieldVariable;
const variable = variableField.getVariable()!;
workspace.deleteVariableById(variable.getId());
(workspace as WorkspaceSvg).refreshToolboxSelection();
const variable = variableField.getVariable();
if (variable) {
Variables.deleteVariable(variable.getWorkspace(), variable, block);
}
};
};

View File

@@ -40,25 +40,26 @@ import {EndRowInput} from './inputs/end_row_input.js';
import {Input} from './inputs/input.js';
import {StatementInput} from './inputs/statement_input.js';
import {ValueInput} from './inputs/value_input.js';
import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
import {isCommentIcon} from './interfaces/i_comment_icon.js';
import {type IIcon} from './interfaces/i_icon.js';
import type {
IVariableModel,
IVariableState,
} from './interfaces/i_variable_model.js';
import * as registry from './registry.js';
import * as Tooltip from './tooltip.js';
import * as arrayUtils from './utils/array.js';
import {Coordinate} from './utils/coordinate.js';
import * as deprecation from './utils/deprecation.js';
import * as idGenerator from './utils/idgenerator.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
import type {VariableModel} from './variable_model.js';
import type {Workspace} from './workspace.js';
/**
* Class for one block.
* Not normally called directly, workspace.newBlock() is preferred.
*/
export class Block implements IASTNodeLocation {
export class Block {
/**
* An optional callback method to use whenever the block's parent workspace
* changes. This is usually only called from the constructor, the block type
@@ -792,7 +793,7 @@ export class Block implements IASTNodeLocation {
this.deletable &&
!this.shadow &&
!this.isDeadOrDying() &&
!this.workspace.options.readOnly
!this.workspace.isReadOnly()
);
}
@@ -825,7 +826,7 @@ export class Block implements IASTNodeLocation {
this.movable &&
!this.shadow &&
!this.isDeadOrDying() &&
!this.workspace.options.readOnly
!this.workspace.isReadOnly()
);
}
@@ -914,7 +915,7 @@ export class Block implements IASTNodeLocation {
*/
isEditable(): boolean {
return (
this.editable && !this.isDeadOrDying() && !this.workspace.options.readOnly
this.editable && !this.isDeadOrDying() && !this.workspace.isReadOnly()
);
}
@@ -934,10 +935,8 @@ export class Block implements IASTNodeLocation {
*/
setEditable(editable: boolean) {
this.editable = editable;
for (let i = 0, input; (input = this.inputList[i]); i++) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
field.updateEditable();
}
for (const field of this.getFields()) {
field.updateEditable();
}
}
@@ -1104,16 +1103,27 @@ export class Block implements IASTNodeLocation {
' instead',
);
}
for (let i = 0, input; (input = this.inputList[i]); i++) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
if (field.name === name) {
return field;
}
for (const field of this.getFields()) {
if (field.name === name) {
return field;
}
}
return null;
}
/**
* Returns a generator that provides every field on the block.
*
* @yields A generator that can be used to iterate the fields on the block.
*/
*getFields(): Generator<Field> {
for (const input of this.inputList) {
for (const field of input.fieldRow) {
yield field;
}
}
}
/**
* Return all variables referenced by this block.
*
@@ -1121,12 +1131,9 @@ export class Block implements IASTNodeLocation {
*/
getVars(): string[] {
const vars: string[] = [];
for (let i = 0, input; (input = this.inputList[i]); i++) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
if (field.referencesVariables()) {
// NOTE: This only applies to `FieldVariable`, a `Field<string>`
vars.push(field.getValue() as string);
}
for (const field of this.getFields()) {
if (field.referencesVariables()) {
vars.push(field.getValue());
}
}
return vars;
@@ -1138,19 +1145,17 @@ export class Block implements IASTNodeLocation {
* @returns List of variable models.
* @internal
*/
getVarModels(): VariableModel[] {
getVarModels(): IVariableModel<IVariableState>[] {
const vars = [];
for (let i = 0, input; (input = this.inputList[i]); i++) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
if (field.referencesVariables()) {
const model = this.workspace.getVariableById(
field.getValue() as string,
);
// Check if the variable actually exists (and isn't just a potential
// variable).
if (model) {
vars.push(model);
}
for (const field of this.getFields()) {
if (field.referencesVariables()) {
const model = this.workspace.getVariableById(
field.getValue() as string,
);
// Check if the variable actually exists (and isn't just a potential
// variable).
if (model) {
vars.push(model);
}
}
}
@@ -1164,15 +1169,13 @@ export class Block implements IASTNodeLocation {
* @param variable The variable being renamed.
* @internal
*/
updateVarName(variable: VariableModel) {
for (let i = 0, input; (input = this.inputList[i]); i++) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
if (
field.referencesVariables() &&
variable.getId() === field.getValue()
) {
field.refreshVariableName();
}
updateVarName(variable: IVariableModel<IVariableState>) {
for (const field of this.getFields()) {
if (
field.referencesVariables() &&
variable.getId() === field.getValue()
) {
field.refreshVariableName();
}
}
}
@@ -1186,11 +1189,9 @@ export class Block implements IASTNodeLocation {
* updated name.
*/
renameVarById(oldId: string, newId: string) {
for (let i = 0, input; (input = this.inputList[i]); i++) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
if (field.referencesVariables() && oldId === field.getValue()) {
field.setValue(newId);
}
for (const field of this.getFields()) {
if (field.referencesVariables() && oldId === field.getValue()) {
field.setValue(newId);
}
}
}
@@ -1408,48 +1409,6 @@ export class Block implements IASTNodeLocation {
return this.disabledReasons.size === 0;
}
/** @deprecated v11 - Get whether the block is manually disabled. */
private get disabled(): boolean {
deprecation.warn(
'disabled',
'v11',
'v12',
'the isEnabled or hasDisabledReason methods of Block',
);
return this.hasDisabledReason(constants.MANUALLY_DISABLED);
}
/** @deprecated v11 - Set whether the block is manually disabled. */
private set disabled(value: boolean) {
deprecation.warn(
'disabled',
'v11',
'v12',
'the setDisabledReason method of Block',
);
this.setDisabledReason(value, constants.MANUALLY_DISABLED);
}
/**
* @deprecated v11 - Set whether the block is manually enabled or disabled.
* The user can toggle whether a block is disabled from a context menu
* option. A block may still be disabled for other reasons even if the user
* attempts to manually enable it, such as when the block is in an invalid
* location. This method is deprecated and setDisabledReason should be used
* instead.
*
* @param enabled True if enabled.
*/
setEnabled(enabled: boolean) {
deprecation.warn(
'setEnabled',
'v11',
'v12',
'the setDisabledReason method of Block',
);
this.setDisabledReason(!enabled, constants.MANUALLY_DISABLED);
}
/**
* Add or remove a reason why the block might be disabled. If a block has
* any reasons to be disabled, then the block itself will be considered
@@ -2516,7 +2475,7 @@ export class Block implements IASTNodeLocation {
*
* Intended to on be used in console logs and errors. If you need a string
* that uses the user's native language (including block text, field values,
* and child blocks), use [toString()]{@link Block#toString}.
* and child blocks), use {@link (Block:class).toString | toString()}.
*
* @returns The description.
*/

View File

@@ -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<BlockSvg>();
protected listeners = new Map<string, browserEvents.Data[]>();
protected flyout?: IFlyout;
private capacityWrapper: (event: AbstractEvent) => void;
/**
* Creates a new BlockFlyoutInflater instance.
*/
constructor() {
this.capacityWrapper = this.filterFlyoutBasedOnCapacity.bind(this);
}
/**
* Inflates a flyout block from the given state and adds it to the flyout.
*
* @param state A JSON representation of a flyout block.
* @param flyout The flyout to create the block on.
* @returns A newly created block.
*/
load(state: object, flyout: IFlyout): FlyoutItem {
this.setFlyout(flyout);
const block = this.createBlock(state as BlockInfo, flyout.getWorkspace());
if (!block.isEnabled()) {
// Record blocks that were initially disabled.
// Do not enable these blocks as a result of capacity filtering.
this.permanentlyDisabledBlocks.add(block);
} else {
this.updateStateBasedOnCapacity(block);
}
// Mark blocks as being inside a flyout. This is used to detect and
// prevent the closure of the flyout if the user right-clicks on such
// a block.
block.getDescendants(false).forEach((b) => (b.isInFlyout = true));
this.addBlockListeners(block);
return new FlyoutItem(block, BLOCK_TYPE);
}
/**
* Creates a block on the given workspace.
*
* @param blockDefinition A JSON representation of the block to create.
* @param workspace The workspace to create the block on.
* @returns The newly created block.
*/
createBlock(blockDefinition: BlockInfo, workspace: WorkspaceSvg): BlockSvg {
let block;
if (blockDefinition['blockxml']) {
const xml = (
typeof blockDefinition['blockxml'] === 'string'
? utilsXml.textToDom(blockDefinition['blockxml'])
: blockDefinition['blockxml']
) as Element;
block = Xml.domToBlockInternal(xml, workspace);
} else {
if (blockDefinition['enabled'] === undefined) {
blockDefinition['enabled'] =
blockDefinition['disabled'] !== 'true' &&
blockDefinition['disabled'] !== true;
}
if (
blockDefinition['disabledReasons'] === undefined &&
blockDefinition['enabled'] === false
) {
blockDefinition['disabledReasons'] = [MANUALLY_DISABLED];
}
// These fields used to be allowed and may still be present, but are
// ignored here since everything in the flyout should always be laid out
// linearly.
if ('x' in blockDefinition) {
delete blockDefinition['x'];
}
if ('y' in blockDefinition) {
delete blockDefinition['y'];
}
block = blocks.appendInternal(blockDefinition as blocks.State, workspace);
}
return block as BlockSvg;
}
/**
* Returns the amount of space that should follow this block.
*
* @param state A JSON representation of a flyout block.
* @param defaultGap The default spacing for flyout items.
* @returns The amount of space that should follow this block.
*/
gapForItem(state: object, defaultGap: number): number {
const blockState = state as BlockInfo;
let gap;
if (blockState['gap']) {
gap = parseInt(String(blockState['gap']));
} else if (blockState['blockxml']) {
const xml = (
typeof blockState['blockxml'] === 'string'
? utilsXml.textToDom(blockState['blockxml'])
: blockState['blockxml']
) as Element;
gap = parseInt(xml.getAttribute('gap')!);
}
return !gap || isNaN(gap) ? defaultGap : gap;
}
/**
* Disposes of the given block.
*
* @param item The flyout block to dispose of.
*/
disposeItem(item: FlyoutItem): void {
const element = item.getElement();
if (!(element instanceof BlockSvg)) return;
this.removeListeners(element.id);
element.dispose(false, false);
}
/**
* Removes event listeners for the block with the given ID.
*
* @param blockId The ID of the block to remove event listeners from.
*/
protected removeListeners(blockId: string) {
const blockListeners = this.listeners.get(blockId) ?? [];
blockListeners.forEach((l) => browserEvents.unbind(l));
this.listeners.delete(blockId);
}
/**
* Updates this inflater's flyout.
*
* @param flyout The flyout that owns this inflater.
*/
protected setFlyout(flyout: IFlyout) {
if (this.flyout === flyout) return;
if (this.flyout) {
this.flyout.targetWorkspace?.removeChangeListener(this.capacityWrapper);
}
this.flyout = flyout;
this.flyout.targetWorkspace?.addChangeListener(this.capacityWrapper);
}
/**
* Updates the enabled state of the given block based on the capacity of the
* workspace.
*
* @param block The block to update the enabled/disabled state of.
*/
private updateStateBasedOnCapacity(block: BlockSvg) {
const enable = this.flyout?.targetWorkspace?.isCapacityAvailable(
common.getBlockTypeCounts(block),
);
let currentBlock: BlockSvg | null = block;
while (currentBlock) {
currentBlock.setDisabledReason(
!enable,
WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON,
);
currentBlock = currentBlock.getNextBlock();
}
}
/**
* Add listeners to a block that has been added to the flyout.
*
* @param block The block to add listeners for.
*/
protected addBlockListeners(block: BlockSvg) {
const blockListeners = [];
blockListeners.push(
browserEvents.conditionalBind(
block.getSvgRoot(),
'pointerdown',
block,
(e: PointerEvent) => {
const gesture = this.flyout?.targetWorkspace?.getGesture(e);
if (gesture && this.flyout) {
gesture.setStartBlock(block);
gesture.handleFlyoutStart(e, this.flyout);
}
},
),
);
blockListeners.push(
browserEvents.bind(block.getSvgRoot(), 'pointermove', null, () => {
if (!this.flyout?.targetWorkspace?.isDragging()) {
block.addSelect();
}
}),
);
blockListeners.push(
browserEvents.bind(block.getSvgRoot(), 'pointerleave', null, () => {
if (!this.flyout?.targetWorkspace?.isDragging()) {
block.removeSelect();
}
}),
);
this.listeners.set(block.id, blockListeners);
}
/**
* Updates the state of blocks in our owning flyout to be disabled/enabled
* based on the capacity of the workspace for more blocks of that type.
*
* @param event The event that triggered this update.
*/
private filterFlyoutBasedOnCapacity(event: AbstractEvent) {
if (
!this.flyout ||
(event &&
!(
event.type === EventType.BLOCK_CREATE ||
event.type === EventType.BLOCK_DELETE
))
)
return;
this.flyout
.getWorkspace()
.getTopBlocks(false)
.forEach((block) => {
if (!this.permanentlyDisabledBlocks.has(block)) {
this.updateStateBasedOnCapacity(block);
}
});
}
/**
* Returns the type of items this inflater is responsible for creating.
*
* @returns An identifier for the type of items this inflater creates.
*/
getType() {
return BLOCK_TYPE;
}
}
registry.register(
registry.Type.FLYOUT_INFLATER,
BLOCK_TYPE,
BlockFlyoutInflater,
);

View File

@@ -16,7 +16,6 @@ import './events/events_selected.js';
import {Block} from './block.js';
import * as blockAnimations from './block_animations.js';
import {IDeletable} from './blockly.js';
import * as browserEvents from './browser_events.js';
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
import * as common from './common.js';
@@ -34,21 +33,21 @@ import {BlockDragStrategy} from './dragging/block_drag_strategy.js';
import type {BlockMove} from './events/events_block_move.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import type {Field} from './field.js';
import {FieldLabel} from './field_label.js';
import {getFocusManager} from './focus_manager.js';
import {IconType} from './icons/icon_types.js';
import {MutatorIcon} from './icons/mutator_icon.js';
import {WarningIcon} from './icons/warning_icon.js';
import type {Input} from './inputs/input.js';
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import {IDeletable} from './interfaces/i_deletable.js';
import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {IIcon} from './interfaces/i_icon.js';
import * as internalConstants from './internal_constants.js';
import {ASTNode} from './keyboard_nav/ast_node.js';
import {TabNavigateCursor} from './keyboard_nav/tab_navigate_cursor.js';
import {MarkerManager} from './marker_manager.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
import {RenderedConnection} from './rendered_connection.js';
@@ -56,8 +55,8 @@ import type {IPathObject} from './renderers/common/i_path_object.js';
import * as blocks from './serialization/blocks.js';
import type {BlockStyle} from './theme.js';
import * as Tooltip from './tooltip.js';
import {idGenerator} from './utils.js';
import {Coordinate} from './utils/coordinate.js';
import * as deprecation from './utils/deprecation.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
import {Svg} from './utils/svg.js';
@@ -73,11 +72,12 @@ import type {WorkspaceSvg} from './workspace_svg.js';
export class BlockSvg
extends Block
implements
IASTNodeLocationSvg,
IBoundedElement,
IContextMenu,
ICopyable<BlockCopyData>,
IDraggable,
IDeletable
IDeletable,
IFocusableNode
{
/**
* Constant for identifying rows that are to be rendered inline.
@@ -194,6 +194,9 @@ export class BlockSvg
this.workspace = workspace;
this.svgGroup = dom.createSvgElement(Svg.G, {});
if (prototypeName) {
dom.addClass(this.svgGroup, prototypeName);
}
/** A block style object. */
this.style = workspace.getRenderer().getConstants().getBlockStyle(null);
@@ -209,6 +212,9 @@ export class BlockSvg
// Expose this block's ID on its top-level SVG group.
this.svgGroup.setAttribute('data-id', this.id);
// The page-wide unique ID of this Block used for focusing.
svgPath.id = idGenerator.getNextUniqueId();
this.doInit_();
}
@@ -228,7 +234,7 @@ export class BlockSvg
this.applyColour();
this.pathObject.updateMovable(this.isMovable() || this.isInFlyout);
const svg = this.getSvgRoot();
if (!this.workspace.options.readOnly && svg) {
if (svg) {
browserEvents.conditionalBind(svg, 'pointerdown', this, this.onMouseDown);
}
@@ -258,20 +264,14 @@ export class BlockSvg
/** Selects this block. Highlights the block visually. */
select() {
if (this.isShadow()) {
this.getParent()?.select();
return;
}
this.addSelect();
common.fireSelectedEvent(this);
}
/** Unselects this block. Unhighlights the block visually. */
unselect() {
if (this.isShadow()) {
this.getParent()?.unselect();
return;
}
this.removeSelect();
common.fireSelectedEvent(null);
}
/**
@@ -303,14 +303,22 @@ export class BlockSvg
(newParent as BlockSvg).getSvgRoot().appendChild(svgRoot);
} else if (oldParent) {
// If we are losing a parent, we want to move our DOM element to the
// root of the workspace.
const draggingBlock = this.workspace
// root of the workspace. Try to insert it before any top-level
// block being dragged, but note that blocks can have the
// blocklyDragging class even if they're not top blocks (especially
// at start and end of a drag).
const draggingBlockElement = this.workspace
.getCanvas()
.querySelector('.blocklyDragging');
if (draggingBlock) {
this.workspace.getCanvas().insertBefore(svgRoot, draggingBlock);
const draggingParentElement = draggingBlockElement?.parentElement as
| SVGElement
| null
| undefined;
const canvas = this.workspace.getCanvas();
if (draggingParentElement === canvas) {
canvas.insertBefore(svgRoot, draggingBlockElement);
} else {
this.workspace.getCanvas().appendChild(svgRoot);
canvas.appendChild(svgRoot);
}
this.translate(oldXY.x, oldXY.y);
}
@@ -507,6 +515,21 @@ export class BlockSvg
this.updateCollapsed();
}
/**
* Traverses child blocks to see if any of them have a warning.
*
* @returns true if any child has a warning, false otherwise.
*/
private childHasWarning(): boolean {
const children = this.getChildren(false);
for (const child of children) {
if (child.getIcon(WarningIcon.TYPE) || child.childHasWarning()) {
return true;
}
}
return false;
}
/**
* Makes sure that when the block is collapsed, it is rendered correctly
* for that state.
@@ -529,9 +552,19 @@ export class BlockSvg
if (!collapsed) {
this.updateDisabled();
this.removeInput(collapsedInputName);
dom.removeClass(this.svgGroup, 'blocklyCollapsed');
this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID);
return;
}
dom.addClass(this.svgGroup, 'blocklyCollapsed');
if (this.childHasWarning()) {
this.setWarningText(
Msg['COLLAPSED_WARNINGS_WARNING'],
BlockSvg.COLLAPSED_WARNING_ID,
);
}
const text = this.toString(internalConstants.COLLAPSE_CHARS);
const field = this.getField(collapsedFieldName);
if (field) {
@@ -544,41 +577,14 @@ export class BlockSvg
input.appendField(new FieldLabel(text), collapsedFieldName);
}
/**
* Open the next (or previous) FieldTextInput.
*
* @param start Current field.
* @param forward If true go forward, otherwise backward.
*/
tab(start: Field, forward: boolean) {
const tabCursor = new TabNavigateCursor();
tabCursor.setCurNode(ASTNode.createFieldNode(start)!);
const currentNode = tabCursor.getCurNode();
if (forward) {
tabCursor.next();
} else {
tabCursor.prev();
}
const nextNode = tabCursor.getCurNode();
if (nextNode && nextNode !== currentNode) {
const nextField = nextNode.getLocation() as Field;
nextField.showEditor();
// Also move the cursor if we're in keyboard nav mode.
if (this.workspace.keyboardAccessibilityMode) {
this.workspace.getCursor()!.setCurNode(nextNode);
}
}
}
/**
* Handle a pointerdown on an SVG block.
*
* @param e Pointer down event.
*/
private onMouseDown(e: PointerEvent) {
if (this.workspace.isReadOnly()) return;
const gesture = this.workspace.getGesture(e);
if (gesture) {
gesture.handleBlockStart(e, this);
@@ -603,15 +609,15 @@ export class BlockSvg
*
* @returns Context menu options or null if no menu.
*/
protected generateContextMenu(): Array<
ContextMenuOption | LegacyContextMenuOption
> | null {
if (this.workspace.options.readOnly || !this.contextMenu) {
protected generateContextMenu(
e: Event,
): Array<ContextMenuOption | LegacyContextMenuOption> | 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 +628,57 @@ export class BlockSvg
return menuOptions;
}
/**
* Gets the location in which to show the context menu for this block.
* Use the location of a click if the block was clicked, or a location
* based on the block's fields otherwise.
*/
protected calculateContextMenuLocation(e: Event): Coordinate {
// Open the menu where the user clicked, if they clicked
if (e instanceof PointerEvent) {
return new Coordinate(e.clientX, e.clientY);
}
// Otherwise, calculate a location.
// Get the location of the top-left corner of the block in
// screen coordinates.
const blockCoords = svgMath.wsToScreenCoordinates(
this.workspace,
this.getRelativeToSurfaceXY(),
);
// Prefer a y position below the first field in the block.
const fieldBoundingClientRect = this.inputList
.filter((input) => input.isVisible())
.flatMap((input) => input.fieldRow)
.find((f) => f.isVisible())
?.getSvgRoot()
?.getBoundingClientRect();
const y =
fieldBoundingClientRect && fieldBoundingClientRect.height
? fieldBoundingClientRect.y + fieldBoundingClientRect.height
: blockCoords.y + this.height;
return new Coordinate(
this.RTL ? blockCoords.x - 5 : blockCoords.x + 5,
y + 5,
);
}
/**
* Show the context menu for this block.
*
* @param e Mouse event.
* @internal
*/
showContextMenu(e: PointerEvent) {
const menuOptions = this.generateContextMenu();
showContextMenu(e: Event) {
const menuOptions = this.generateContextMenu(e);
const location = this.calculateContextMenuLocation(e);
if (menuOptions && menuOptions.length) {
ContextMenu.show(e, menuOptions, this.RTL, this.workspace);
ContextMenu.show(e, menuOptions, this.RTL, this.workspace, location);
ContextMenu.setCurrentBlock(this);
}
}
@@ -676,6 +722,24 @@ export class BlockSvg
}
}
/**
* Add a CSS class to the SVG group of this block.
*
* @param className
*/
addClass(className: string) {
dom.addClass(this.svgGroup, className);
}
/**
* Remove a CSS class from the SVG group of this block.
*
* @param className
*/
removeClass(className: string) {
dom.removeClass(this.svgGroup, className);
}
/**
* Recursively adds or removes the dragging class to this node and its
* children.
@@ -688,10 +752,10 @@ export class BlockSvg
if (adding) {
this.translation = '';
common.draggingConnections.push(...this.getConnections_(true));
dom.addClass(this.svgGroup, 'blocklyDragging');
this.addClass('blocklyDragging');
} else {
common.draggingConnections.length = 0;
dom.removeClass(this.svgGroup, 'blocklyDragging');
this.removeClass('blocklyDragging');
}
// Recurse through all blocks attached under this one.
for (let i = 0; i < this.childBlocks_.length; i++) {
@@ -716,6 +780,13 @@ export class BlockSvg
*/
override setEditable(editable: boolean) {
super.setEditable(editable);
if (editable) {
dom.removeClass(this.svgGroup, 'blocklyNotEditable');
} else {
dom.addClass(this.svgGroup, 'blocklyNotEditable');
}
const icons = this.getIcons();
for (let i = 0; i < icons.length; i++) {
icons[i].updateEditable();
@@ -783,25 +854,6 @@ export class BlockSvg
blockAnimations.disposeUiEffect(this);
}
// Selecting a shadow block highlights an ancestor block, but that highlight
// should be removed if the shadow block will be deleted. So, before
// deleting blocks and severing the connections between them, check whether
// doing so would delete a selected block and make sure that any associated
// parent is updated.
const selection = common.getSelected();
if (selection instanceof Block) {
let selectionAncestor: Block | null = selection;
while (selectionAncestor !== null) {
if (selectionAncestor === this) {
// The block to be deleted contains the selected block, so remove any
// selection highlight associated with the selected block before
// deleting them.
selection.unselect();
}
selectionAncestor = selectionAncestor.getParent();
}
}
super.dispose(!!healStack);
dom.removeNode(this.svgGroup);
}
@@ -814,8 +866,7 @@ export class BlockSvg
this.disposing = true;
super.disposeInternal();
if (common.getSelected() === this) {
this.unselect();
if (getFocusManager().getFocusedNode() === this) {
this.workspace.cancelCurrentGesture();
}
@@ -873,17 +924,15 @@ export class BlockSvg
* @internal
*/
applyColour() {
this.pathObject.applyColour(this);
this.pathObject.applyColour?.(this);
const icons = this.getIcons();
for (let i = 0; i < icons.length; i++) {
icons[i].applyColour();
}
for (let x = 0, input; (input = this.inputList[x]); x++) {
for (let y = 0, field; (field = input.fieldRow[y]); y++) {
field.applyColour();
}
for (const field of this.getFields()) {
field.applyColour();
}
}
@@ -1029,30 +1078,6 @@ export class BlockSvg
return removed;
}
/**
* @deprecated v11 - Set whether the block is manually enabled or disabled.
* The user can toggle whether a block is disabled from a context menu
* option. A block may still be disabled for other reasons even if the user
* attempts to manually enable it, such as when the block is in an invalid
* location. This method is deprecated and setDisabledReason should be used
* instead.
*
* @param enabled True if enabled.
*/
override setEnabled(enabled: boolean) {
deprecation.warn(
'setEnabled',
'v11',
'v12',
'the setDisabledReason method of BlockSvg',
);
const wasEnabled = this.isEnabled();
super.setEnabled(enabled);
if (this.isEnabled() !== wasEnabled && !this.getInheritedDisabled()) {
this.updateDisabled();
}
}
/**
* Add or remove a reason why the block might be disabled. If a block has
* any reasons to be disabled, then the block itself will be considered
@@ -1075,6 +1100,20 @@ export class BlockSvg
}
}
/**
* Add blocklyNotDeletable class when block is not deletable
* Or remove class when block is deletable
*/
override setDeletable(deletable: boolean) {
super.setDeletable(deletable);
if (deletable) {
dom.removeClass(this.svgGroup, 'blocklyNotDeletable');
} else {
dom.addClass(this.svgGroup, 'blocklyNotDeletable');
}
}
/**
* Set whether the block is highlighted or not. Block highlighting is
* often used to visually mark blocks currently being executed.
@@ -1139,7 +1178,7 @@ export class BlockSvg
.getConstants()
.getBlockStyleForColour(this.colour_);
this.pathObject.setStyle(styleObj.style);
this.pathObject.setStyle?.(styleObj.style);
this.style = styleObj.style;
this.styleName_ = styleObj.name;
@@ -1157,16 +1196,22 @@ export class BlockSvg
.getRenderer()
.getConstants()
.getBlockStyle(blockStyleName);
this.styleName_ = blockStyleName;
if (this.styleName_) {
dom.removeClass(this.svgGroup, this.styleName_);
}
if (blockStyle) {
this.hat = blockStyle.hat;
this.pathObject.setStyle(blockStyle);
this.pathObject.setStyle?.(blockStyle);
// Set colour to match Block.
this.colour_ = blockStyle.colourPrimary;
this.style = blockStyle;
this.applyColour();
dom.addClass(this.svgGroup, blockStyleName);
this.styleName_ = blockStyleName;
} else {
throw Error('Invalid style name: ' + blockStyleName);
}
@@ -1193,6 +1238,7 @@ export class BlockSvg
* adjusting its parents.
*/
bringToFront(blockOnly = false) {
const previouslyFocused = getFocusManager().getFocusedNode();
/* eslint-disable-next-line @typescript-eslint/no-this-alias */
let block: this | null = this;
if (block.isDeadOrDying()) {
@@ -1209,6 +1255,13 @@ export class BlockSvg
if (blockOnly) break;
block = block.getParent();
} while (block);
if (previouslyFocused) {
// Bringing a block to the front of the stack doesn't fundamentally change
// the logical structure of the page, but it does change element ordering
// which can take automatically take away focus from a node. Ensure focus
// is restored to avoid a discontinuity.
getFocusManager().focusNode(previouslyFocused);
}
}
/**
@@ -1571,7 +1624,6 @@ export class BlockSvg
this.tightenChildrenEfficiently();
dom.stopTextWidthCache();
this.updateMarkers_();
}
/**
@@ -1591,44 +1643,6 @@ export class BlockSvg
if (this.nextConnection) this.nextConnection.tightenEfficiently();
}
/** Redraw any attached marker or cursor svgs if needed. */
protected updateMarkers_() {
if (this.workspace.keyboardAccessibilityMode && this.pathObject.cursorSvg) {
this.workspace.getCursor()!.draw();
}
if (this.workspace.keyboardAccessibilityMode && this.pathObject.markerSvg) {
// TODO(#4592): Update all markers on the block.
this.workspace.getMarker(MarkerManager.LOCAL_MARKER)!.draw();
}
for (const input of this.inputList) {
for (const field of input.fieldRow) {
field.updateMarkers_();
}
}
}
/**
* Add the cursor SVG to this block's SVG group.
*
* @param cursorSvg The SVG root of the cursor to be added to the block SVG
* group.
* @internal
*/
setCursorSvg(cursorSvg: SVGElement) {
this.pathObject.setCursorSvg(cursorSvg);
}
/**
* Add the marker SVG to this block's SVG group.
*
* @param markerSvg The SVG root of the marker to be added to the block SVG
* group.
* @internal
*/
setMarkerSvg(markerSvg: SVGElement) {
this.pathObject.setMarkerSvg(markerSvg);
}
/**
* Returns a bounding box describing the dimensions of this block
* and any blocks stacked below it.
@@ -1681,6 +1695,16 @@ export class BlockSvg
);
}
/**
* Returns the drag strategy currently in use by this block.
*
* @internal
* @returns This block's drag strategy.
*/
getDragStrategy(): IDragStrategy {
return this.dragStrategy;
}
/** Sets the drag strategy for this block. */
setDragStrategy(dragStrategy: IDragStrategy) {
this.dragStrategy = dragStrategy;
@@ -1736,4 +1760,41 @@ export class BlockSvg
traverseJson(json as unknown as {[key: string]: unknown});
return [json];
}
override jsonInit(json: AnyDuringMigration): void {
super.jsonInit(json);
if (json['classes']) {
this.addClass(
Array.isArray(json['classes'])
? json['classes'].join(' ')
: json['classes'],
);
}
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
return this.pathObject.svgPath;
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this.workspace;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.select();
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {
this.unselect();
}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
}

View File

@@ -17,6 +17,7 @@ import './events/events_var_create.js';
import {Block} from './block.js';
import * as blockAnimations from './block_animations.js';
import {BlockFlyoutInflater} from './block_flyout_inflater.js';
import {BlockSvg} from './block_svg.js';
import {BlocklyOptions} from './blockly_options.js';
import {Blocks} from './blocks.js';
@@ -24,6 +25,7 @@ import * as browserEvents from './browser_events.js';
import * as bubbles from './bubbles.js';
import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js';
import * as bumpObjects from './bump_objects.js';
import {ButtonFlyoutInflater} from './button_flyout_inflater.js';
import * as clipboard from './clipboard.js';
import * as comments from './comments.js';
import * as common from './common.js';
@@ -62,6 +64,7 @@ import {
FieldDropdownConfig,
FieldDropdownFromJsonConfig,
FieldDropdownValidator,
ImageProperties,
MenuGenerator,
MenuGeneratorFunction,
MenuOption,
@@ -99,20 +102,28 @@ import {
import {Flyout} from './flyout_base.js';
import {FlyoutButton} from './flyout_button.js';
import {HorizontalFlyout} from './flyout_horizontal.js';
import {FlyoutItem} from './flyout_item.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutSeparator} from './flyout_separator.js';
import {VerticalFlyout} from './flyout_vertical.js';
import {
FocusManager,
ReturnEphemeralFocus,
getFocusManager,
} from './focus_manager.js';
import {CodeGenerator} from './generator.js';
import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
import * as icons from './icons.js';
import {inject} from './inject.js';
import * as inputs from './inputs.js';
import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import {LabelFlyoutInflater} from './label_flyout_inflater.js';
import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
import {Input} from './inputs/input.js';
import {InsertionMarkerManager} from './insertion_marker_manager.js';
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
import {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
import {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IBubble} from './interfaces/i_bubble.js';
@@ -132,6 +143,8 @@ import {
} from './interfaces/i_draggable.js';
import {IDragger} from './interfaces/i_dragger.js';
import {IFlyout} from './interfaces/i_flyout.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {IHasBubble, hasBubble} from './interfaces/i_has_bubble.js';
import {IIcon, isIcon} from './interfaces/i_icon.js';
import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
@@ -155,12 +168,11 @@ import {
IVariableBackedParameterModel,
isVariableBackedParameterModel,
} from './interfaces/i_variable_backed_parameter_model.js';
import {IVariableMap} from './interfaces/i_variable_map.js';
import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js';
import * as internalConstants from './internal_constants.js';
import {ASTNode} from './keyboard_nav/ast_node.js';
import {BasicCursor} from './keyboard_nav/basic_cursor.js';
import {Cursor} from './keyboard_nav/cursor.js';
import {LineCursor} from './keyboard_nav/line_cursor.js';
import {Marker} from './keyboard_nav/marker.js';
import {TabNavigateCursor} from './keyboard_nav/tab_navigate_cursor.js';
import type {LayerManager} from './layer_manager.js';
import * as layers from './layers.js';
import {MarkerManager} from './marker_manager.js';
@@ -417,10 +429,20 @@ Names.prototype.populateProcedures = function (
};
// clang-format on
export * from './flyout_navigator.js';
export * from './interfaces/i_navigation_policy.js';
export * from './keyboard_nav/block_navigation_policy.js';
export * from './keyboard_nav/connection_navigation_policy.js';
export * from './keyboard_nav/field_navigation_policy.js';
export * from './keyboard_nav/flyout_button_navigation_policy.js';
export * from './keyboard_nav/flyout_navigation_policy.js';
export * from './keyboard_nav/flyout_separator_navigation_policy.js';
export * from './keyboard_nav/workspace_navigation_policy.js';
export * from './navigator.js';
export * from './toast.js';
// Re-export submodules that no longer declareLegacyNamespace.
export {
ASTNode,
BasicCursor,
Block,
BlockSvg,
BlocklyOptions,
@@ -435,11 +457,11 @@ export {
ContextMenuItems,
ContextMenuRegistry,
Css,
Cursor,
DeleteArea,
DragTarget,
Events,
Extensions,
LineCursor,
Procedures,
ShortcutItems,
Themes,
@@ -471,6 +493,8 @@ export {
};
export const DropDownDiv = dropDownDiv;
export {
BlockFlyoutInflater,
ButtonFlyoutInflater,
CodeGenerator,
Field,
FieldCheckbox,
@@ -504,14 +528,15 @@ export {
FieldVariableValidator,
Flyout,
FlyoutButton,
FlyoutItem,
FlyoutMetricsManager,
FlyoutSeparator,
FocusManager,
FocusableTreeTraverser,
CodeGenerator as Generator,
Gesture,
Grid,
HorizontalFlyout,
IASTNodeLocation,
IASTNodeLocationSvg,
IASTNodeLocationWithBlock,
IAutoHideable,
IBoundedElement,
IBubble,
@@ -529,6 +554,9 @@ export {
IDraggable,
IDragger,
IFlyout,
IFlyoutInflater,
IFocusableNode,
IFocusableTree,
IHasBubble,
IIcon,
IKeyboardAccessible,
@@ -546,9 +574,13 @@ export {
IToolbox,
IToolboxItem,
IVariableBackedParameterModel,
IVariableMap,
IVariableModel,
IVariableState,
ImageProperties,
Input,
InsertionMarkerManager,
InsertionMarkerPreviewer,
LabelFlyoutInflater,
LayerManager,
Marker,
MarkerManager,
@@ -562,10 +594,11 @@ export {
Names,
Options,
RenderedConnection,
ReturnEphemeralFocus,
Scrollbar,
ScrollbarPair,
SeparatorFlyoutInflater,
ShortcutRegistry,
TabNavigateCursor,
Theme,
ThemeManager,
Toolbox,
@@ -583,6 +616,7 @@ export {
WorkspaceSvg,
ZoomControls,
config,
getFocusManager,
hasBubble,
icons,
inject,

View File

@@ -4,11 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {ISelectable} from '../blockly.js';
import * as browserEvents from '../browser_events.js';
import * as common from '../common.js';
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
import {getFocusManager} from '../focus_manager.js';
import {IBubble} from '../interfaces/i_bubble.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Scrollbar} from '../scrollbar.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -86,17 +88,24 @@ export abstract class Bubble implements IBubble, ISelectable {
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
private focusableElement: SVGElement | HTMLElement;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
* The tail of the bubble will point to this location.
* @param ownerRect An optional rect we don't want the bubble to overlap with
* when automatically positioning.
* @param overriddenFocusableElement An optional replacement to the focusable
* element that's represented by this bubble (as a focusable node). This
* element will have its ID and tabindex overwritten. If not provided, the
* focusable element of this node will default to the bubble's SVG root.
*/
constructor(
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
overriddenFocusableElement?: SVGElement | HTMLElement,
) {
this.id = idGenerator.getNextUniqueId();
this.svgRoot = dom.createSvgElement(
@@ -106,11 +115,7 @@ export abstract class Bubble implements IBubble, ISelectable {
);
const embossGroup = dom.createSvgElement(
Svg.G,
{
'filter': `url(#${
this.workspace.getRenderer().getConstants().embossFilterId
})`,
},
{'class': 'blocklyEmboss'},
this.svgRoot,
);
this.tail = dom.createSvgElement(
@@ -131,6 +136,10 @@ export abstract class Bubble implements IBubble, ISelectable {
);
this.contentContainer = dom.createSvgElement(Svg.G, {}, this.svgRoot);
this.focusableElement = overriddenFocusableElement ?? this.svgRoot;
this.focusableElement.setAttribute('id', this.id);
this.focusableElement.setAttribute('tabindex', '-1');
browserEvents.conditionalBind(
this.background,
'pointerdown',
@@ -212,11 +221,13 @@ export abstract class Bubble implements IBubble, ISelectable {
this.background.setAttribute('fill', colour);
}
/** Brings the bubble to the front and passes the pointer event off to the gesture system. */
/**
* Passes the pointer event off to the gesture system and ensures the bubble
* is focused.
*/
private onMouseDown(e: PointerEvent) {
this.workspace.getGesture(e)?.handleBubbleStart(e, this);
this.bringToFront();
common.setSelected(this);
getFocusManager().focusNode(this);
}
/** Positions the bubble relative to its anchor. Does not render its tail. */
@@ -651,9 +662,37 @@ export abstract class Bubble implements IBubble, ISelectable {
select(): void {
// Bubbles don't have any visual for being selected.
common.fireSelectedEvent(this);
}
unselect(): void {
// Bubbles don't have any visual for being selected.
common.fireSelectedEvent(null);
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
return this.focusableElement;
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this.workspace;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.select();
this.bringToFront();
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {
this.unselect();
}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
}

View File

@@ -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()

View File

@@ -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. */

View File

@@ -48,6 +48,9 @@ export class TextInputBubble extends Bubble {
/** Functions listening for changes to the size of this bubble. */
private sizeChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the location of this bubble. */
private locationChangeListeners: (() => void)[] = [];
/** The text of this bubble. */
private text = '';
@@ -77,11 +80,10 @@ export class TextInputBubble extends Bubble {
protected anchor: Coordinate,
protected ownerRect?: Rect,
) {
super(workspace, anchor, ownerRect);
super(workspace, anchor, ownerRect, TextInputBubble.createTextArea());
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
({inputRoot: this.inputRoot, textArea: this.textArea} = this.createEditor(
this.contentContainer,
));
this.textArea = this.getFocusableElement() as HTMLTextAreaElement;
this.inputRoot = this.createEditor(this.contentContainer, this.textArea);
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
}
@@ -123,11 +125,26 @@ export class TextInputBubble extends Bubble {
this.sizeChangeListeners.push(listener);
}
/** Creates the editor UI for this bubble. */
private createEditor(container: SVGGElement): {
inputRoot: SVGForeignObjectElement;
textArea: HTMLTextAreaElement;
} {
/** Adds a change listener to be notified when this bubble's location changes. */
addLocationChangeListener(listener: () => void) {
this.locationChangeListeners.push(listener);
}
/** Creates and returns the editable text area for this bubble's editor. */
private static createTextArea(): HTMLTextAreaElement {
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
textArea.className = 'blocklyTextarea blocklyText';
return textArea;
}
/** Creates and returns the UI container element for this bubble's editor. */
private createEditor(
container: SVGGElement,
textArea: HTMLTextAreaElement,
): SVGForeignObjectElement {
const inputRoot = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
@@ -141,22 +158,13 @@ export class TextInputBubble extends Bubble {
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
textArea.className = 'blocklyTextarea blocklyText';
textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
body.appendChild(textArea);
inputRoot.appendChild(body);
this.bindTextAreaEvents(textArea);
setTimeout(() => {
textArea.focus();
}, 0);
return {inputRoot, textArea};
return inputRoot;
}
/** Binds events to the text area element. */
@@ -166,13 +174,6 @@ export class TextInputBubble extends Bubble {
e.stopPropagation();
});
browserEvents.conditionalBind(
textArea,
'focus',
this,
this.onStartEdit,
true,
);
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
}
@@ -230,10 +231,25 @@ export class TextInputBubble extends Bubble {
/** @returns the size of this bubble. */
getSize(): Size {
// Overriden to be public.
// Overridden to be public.
return super.getSize();
}
override moveDuringDrag(newLoc: Coordinate) {
super.moveDuringDrag(newLoc);
this.onLocationChange();
}
override setPositionRelativeToAnchor(left: number, top: number) {
super.setPositionRelativeToAnchor(left, top);
this.onLocationChange();
}
protected override positionByRect(rect = new Rect(0, 0, 0, 0)) {
super.positionByRect(rect);
this.onLocationChange();
}
/** Handles mouse down events on the resize target. */
private onResizePointerDown(e: PointerEvent) {
this.bringToFront();
@@ -291,17 +307,6 @@ export class TextInputBubble extends Bubble {
this.onSizeChange();
}
/**
* Handles starting an edit of the text area. Brings the bubble to the front.
*/
private onStartEdit() {
if (this.bringToFront()) {
// Since the act of moving this node within the DOM causes a loss of
// focus, we need to reapply the focus.
this.textArea.focus();
}
}
/** Handles a text change event for the text area. Calls event listeners. */
private onTextChange() {
this.text = this.textArea.value;
@@ -316,6 +321,13 @@ export class TextInputBubble extends Bubble {
listener();
}
}
/** Handles a location change event for the text area. Calls event listeners. */
private onLocationChange() {
for (const listener of this.locationChangeListeners) {
listener();
}
}
}
Css.register(`

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {FlyoutButton} from './flyout_button.js';
import {FlyoutItem} from './flyout_item.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import * as registry from './registry.js';
import {ButtonOrLabelInfo} from './utils/toolbox.js';
const BUTTON_TYPE = 'button';
/**
* Class responsible for creating buttons for flyouts.
*/
export class ButtonFlyoutInflater implements IFlyoutInflater {
/**
* Inflates a flyout button from the given state and adds it to the flyout.
*
* @param state A JSON representation of a flyout button.
* @param flyout The flyout to create the button on.
* @returns A newly created FlyoutButton.
*/
load(state: object, flyout: IFlyout): FlyoutItem {
const button = new FlyoutButton(
flyout.getWorkspace(),
flyout.targetWorkspace!,
state as ButtonOrLabelInfo,
false,
);
button.show();
return new FlyoutItem(button, BUTTON_TYPE);
}
/**
* Returns the amount of space that should follow this button.
*
* @param state A JSON representation of a flyout button.
* @param defaultGap The default spacing for flyout items.
* @returns The amount of space that should follow this button.
*/
gapForItem(state: object, defaultGap: number): number {
return defaultGap;
}
/**
* Disposes of the given button.
*
* @param item The flyout button to dispose of.
*/
disposeItem(item: FlyoutItem): void {
const element = item.getElement();
if (element instanceof FlyoutButton) {
element.dispose();
}
}
/**
* Returns the type of items this inflater is responsible for creating.
*
* @returns An identifier for the type of items this inflater creates.
*/
getType() {
return BUTTON_TYPE;
}
}
registry.register(
registry.Type.FLYOUT_INFLATER,
BUTTON_TYPE,
ButtonFlyoutInflater,
);

View File

@@ -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};

View File

@@ -5,12 +5,14 @@
*/
import {BlockSvg} from '../block_svg.js';
import * as common from '../common.js';
import {IFocusableNode} from '../blockly.js';
import {config} from '../config.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {ICopyData} from '../interfaces/i_copyable.js';
import {IPaster} from '../interfaces/i_paster.js';
import * as renderManagement from '../render_management.js';
import {State, append} from '../serialization/blocks.js';
import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -55,7 +57,13 @@ export class BlockPaster implements IPaster<BlockCopyData, BlockSvg> {
if (eventUtils.isEnabled() && !block.isShadow()) {
eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(block));
}
common.setSelected(block);
// Sometimes there's a delay before the block is fully created and ready for
// focusing, so wait slightly before focusing the newly pasted block.
const nodeToFocus: IFocusableNode = block;
renderManagement
.finishQueuedRenders()
.then(() => getFocusManager().focusNode(nodeToFocus));
return block;
}
}

View File

@@ -5,9 +5,9 @@
*/
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import * as common from '../common.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {ICopyData} from '../interfaces/i_copyable.js';
import {IPaster} from '../interfaces/i_paster.js';
import * as commentSerialiation from '../serialization/workspace_comments.js';
@@ -49,7 +49,7 @@ export class WorkspaceCommentPaster
if (eventUtils.isEnabled()) {
eventUtils.fire(new (eventUtils.get(EventType.COMMENT_CREATE))(comment));
}
common.setSelected(comment);
getFocusManager().focusNode(comment);
return comment;
}
}

View File

@@ -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);

View File

@@ -13,11 +13,13 @@ import * as common from '../common.js';
import * as contextMenu from '../contextmenu.js';
import {ContextMenuRegistry} from '../contextmenu_registry.js';
import {CommentDragStrategy} from '../dragging/comment_drag_strategy.js';
import {getFocusManager} from '../focus_manager.js';
import {IBoundedElement} from '../interfaces/i_bounded_element.js';
import {IContextMenu} from '../interfaces/i_contextmenu.js';
import {ICopyable} from '../interfaces/i_copyable.js';
import {IDeletable} from '../interfaces/i_deletable.js';
import {IDraggable} from '../interfaces/i_draggable.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import * as layers from '../layers.js';
@@ -26,6 +28,7 @@ import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import * as svgMath from '../utils/svg_math.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {CommentView} from './comment_view.js';
import {WorkspaceComment} from './workspace_comment.js';
@@ -59,6 +62,8 @@ export class RenderedWorkspaceComment
this.view.setSize(this.getSize());
this.view.setEditable(this.isEditable());
this.view.getSvgRoot().setAttribute('data-id', this.id);
this.view.getSvgRoot().setAttribute('id', this.id);
this.view.getSvgRoot().setAttribute('tabindex', '-1');
this.addModelUpdateBindings();
@@ -105,6 +110,11 @@ export class RenderedWorkspaceComment
this.view.setText(text);
}
/** Sets the placeholder text displayed if the comment is empty. */
setPlaceholderText(text: string): void {
this.view.setPlaceholderText(text);
}
/** Sets the size of the comment. */
override setSize(size: Size) {
// setSize will trigger the change listener that updates
@@ -214,9 +224,8 @@ export class RenderedWorkspaceComment
e.stopPropagation();
} else {
gesture.handleCommentStart(e, this);
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
}
common.setSelected(this);
getFocusManager().focusNode(this);
}
}
@@ -257,11 +266,13 @@ export class RenderedWorkspaceComment
/** Visually highlights the comment. */
select(): void {
dom.addClass(this.getSvgRoot(), 'blocklySelected');
common.fireSelectedEvent(this);
}
/** Visually unhighlights the comment. */
unselect(): void {
dom.removeClass(this.getSvgRoot(), 'blocklySelected');
common.fireSelectedEvent(null);
}
/**
@@ -278,12 +289,31 @@ export class RenderedWorkspaceComment
}
/** Show a context menu for this comment. */
showContextMenu(e: PointerEvent): void {
showContextMenu(e: Event): void {
const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
ContextMenuRegistry.ScopeType.COMMENT,
{comment: this},
{comment: this, focusedNode: this},
e,
);
let location: Coordinate;
if (e instanceof PointerEvent) {
location = new Coordinate(e.clientX, e.clientY);
} else {
// Show the menu based on the location of the comment
const xy = svgMath.wsToScreenCoordinates(
this.workspace,
this.getRelativeToSurfaceXY(),
);
location = xy.translate(10, 10);
}
contextMenu.show(
e,
menuOptions,
this.workspace.RTL,
this.workspace,
location,
);
contextMenu.show(e, menuOptions, this.workspace.RTL, this.workspace);
}
/** Snap this comment to the nearest grid point. */
@@ -297,4 +327,31 @@ export class RenderedWorkspaceComment
this.moveTo(alignedXY, ['snap']);
}
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
return this.getSvgRoot();
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this.workspace;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.select();
// Ensure that the comment is always at the top when focused.
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {
this.unselect();
}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
}

View File

@@ -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()
);
}

View File

@@ -7,11 +7,12 @@
// Former goog.module ID: Blockly.common
import type {Block} from './block.js';
import {ISelectable} from './blockly.js';
import {BlockDefinition, Blocks} from './blocks.js';
import type {Connection} from './connection.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {ISelectable, isSelectable} from './interfaces/i_selectable.js';
import type {Workspace} from './workspace.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -86,38 +87,45 @@ export function setMainWorkspace(workspace: Workspace) {
}
/**
* Currently selected copyable object.
*/
let selected: ISelectable | null = null;
/**
* Returns the currently selected copyable object.
* Returns the current selection.
*/
export function getSelected(): ISelectable | null {
return selected;
const focused = getFocusManager().getFocusedNode();
if (focused && isSelectable(focused)) return focused;
return null;
}
/**
* Sets the currently selected block. This function does not visually mark the
* block as selected or fire the required events. If you wish to
* programmatically select a block, use `BlockSvg#select`.
* Sets the current selection.
*
* @param newSelection The newly selected block.
* To clear the current selection, select another ISelectable or focus a
* non-selectable (like the workspace root node).
*
* @param newSelection The new selection to make.
* @internal
*/
export function setSelected(newSelection: ISelectable | null) {
if (selected === newSelection) return;
export function setSelected(newSelection: ISelectable) {
getFocusManager().focusNode(newSelection);
}
/**
* Fires a selection change event based on the new selection.
*
* This is only expected to be called by ISelectable implementations and should
* always be called before updating the current selection state. It does not
* change focus or selection state.
*
* @param newSelection The new selection.
* @internal
*/
export function fireSelectedEvent(newSelection: ISelectable | null) {
const selected = getSelected();
const event = new (eventUtils.get(EventType.SELECTED))(
selected?.id ?? null,
newSelection?.id ?? null,
newSelection?.workspace.id ?? selected?.workspace.id ?? '',
);
eventUtils.fire(event);
selected?.unselect();
selected = newSelection;
selected?.select();
}
/**

View File

@@ -17,15 +17,15 @@ import type {BlockMove} from './events/events_block_move.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import type {Input} from './inputs/input.js';
import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js';
import type {IConnectionChecker} from './interfaces/i_connection_checker.js';
import * as blocks from './serialization/blocks.js';
import {idGenerator} from './utils.js';
import * as Xml from './xml.js';
/**
* Class for a connection between blocks.
*/
export class Connection implements IASTNodeLocationWithBlock {
export class Connection {
/** Constants for checking whether two connections are compatible. */
static CAN_CONNECT = 0;
static REASON_SELF_CONNECTION = 1;
@@ -55,6 +55,9 @@ export class Connection implements IASTNodeLocationWithBlock {
/** DOM representation of a shadow block, or null if none. */
private shadowDom: Element | null = null;
/** The unique ID of this connection. */
id: string;
/**
* Horizontal location of this connection.
*
@@ -80,6 +83,7 @@ export class Connection implements IASTNodeLocationWithBlock {
public type: number,
) {
this.sourceBlock_ = source;
this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`;
}
/**
@@ -485,7 +489,7 @@ export class Connection implements IASTNodeLocationWithBlock {
*
* Headless configurations (the default) do not have neighboring connection,
* and always return an empty list (the default).
* {@link RenderedConnection#neighbours} overrides this behavior with a list
* {@link (RenderedConnection:class).neighbours} overrides this behavior with a list
* computed from the rendered positioning.
*
* @param _maxLimit The maximum radius to another connection.

View File

@@ -9,7 +9,6 @@
import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import {config} from './config.js';
import type {
ContextMenuOption,
@@ -17,10 +16,13 @@ import type {
} from './contextmenu_registry.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import * as serializationBlocks from './serialization/blocks.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
import * as svgMath from './utils/svg_math.js';
@@ -37,6 +39,8 @@ const dummyOwner = {};
/**
* Gets the block the context menu is currently attached to.
* It is not recommended that you use this function; instead,
* use the scope object passed to the context menu callback.
*
* @returns The block the context menu is attached to.
*/
@@ -61,26 +65,38 @@ let menu_: Menu | null = null;
/**
* Construct the menu based on the list of options and show the menu.
*
* @param e Mouse event.
* @param menuOpenEvent Event that caused the menu to open.
* @param options Array of menu options.
* @param rtl True if RTL, false if LTR.
* @param workspace The workspace associated with the context menu, if any.
* @param location The screen coordinates at which to show the menu.
*/
export function show(
e: PointerEvent,
menuOpenEvent: Event,
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
workspace?: WorkspaceSvg,
location?: Coordinate,
) {
WidgetDiv.show(dummyOwner, rtl, dispose, workspace);
if (!options.length) {
hide();
return;
}
const menu = populate_(options, rtl, e);
if (!location) {
if (menuOpenEvent instanceof PointerEvent) {
location = new Coordinate(menuOpenEvent.clientX, menuOpenEvent.clientY);
} else {
// We got a keyboard event that didn't tell us where to open the menu, so just guess
console.warn('Context menu opened with keyboard but no location given');
location = new Coordinate(0, 0);
}
}
const menu = populate_(options, rtl, menuOpenEvent, location);
menu_ = menu;
position_(menu, e, rtl);
position_(menu, rtl, location);
// 1ms delay is required for focusing on context menus because some other
// mouse event is still waiting in the queue and clears focus.
setTimeout(function () {
@@ -94,13 +110,15 @@ export function show(
*
* @param options Array of menu options.
* @param rtl True if RTL, false if LTR.
* @param e The event that triggered the context menu to open.
* @param menuOpenEvent The event that triggered the context menu to open.
* @param location The screen coordinates at which to show the menu.
* @returns The menu that will be shown on right click.
*/
function populate_(
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
e: PointerEvent,
menuOpenEvent: Event,
location: Coordinate,
): Menu {
/* Here's what one option object looks like:
{text: 'Make It So',
@@ -111,13 +129,18 @@ function populate_(
menu.setRole(aria.Role.MENU);
for (let i = 0; i < options.length; i++) {
const option = options[i];
if (option.separator) {
menu.addChild(new MenuSeparator());
continue;
}
const menuItem = new MenuItem(option.text);
menuItem.setRightToLeft(rtl);
menuItem.setRole(aria.Role.MENUITEM);
menu.addChild(menuItem);
menuItem.setEnabled(option.enabled);
if (option.enabled) {
const actionHandler = function () {
const actionHandler = function (p1: MenuItem, menuSelectEvent: Event) {
hide();
requestAnimationFrame(() => {
setTimeout(() => {
@@ -125,7 +148,12 @@ function populate_(
// will not be expecting a scope parameter, so there should be
// no problems. Just assume it is a ContextMenuOption and we'll
// pass undefined if it's not.
option.callback((option as ContextMenuOption).scope, e);
option.callback(
(option as ContextMenuOption).scope,
menuOpenEvent,
menuSelectEvent,
location,
);
}, 0);
});
};
@@ -139,21 +167,19 @@ function populate_(
* Add the menu to the page and position it correctly.
*
* @param menu The menu to add and position.
* @param e Mouse event for the right click that is making the context
* menu appear.
* @param rtl True if RTL, false if LTR.
* @param location The location at which to anchor the menu.
*/
function position_(menu: Menu, e: Event, rtl: boolean) {
function position_(menu: Menu, rtl: boolean, location: Coordinate) {
// Record windowSize and scrollOffset before adding menu.
const viewportBBox = svgMath.getViewportBBox();
const mouseEvent = e as MouseEvent;
// This one is just a point, but we'll pretend that it's a rect so we can use
// some helper functions.
const anchorBBox = new Rect(
mouseEvent.clientY + viewportBBox.top,
mouseEvent.clientY + viewportBBox.top,
mouseEvent.clientX + viewportBBox.left,
mouseEvent.clientX + viewportBBox.left,
location.y + viewportBBox.top,
location.y + viewportBBox.top,
location.x + viewportBBox.left,
location.x + viewportBBox.left,
);
createWidget_(menu);
@@ -263,7 +289,7 @@ export function callbackFactory(
if (eventUtils.isEnabled() && !newBlock.isShadow()) {
eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock));
}
common.setSelected(newBlock);
getFocusManager().focusNode(newBlock);
return newBlock;
};
}

View File

@@ -9,7 +9,6 @@
import type {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import * as common from './common.js';
import {MANUALLY_DISABLED} from './constants.js';
import {
ContextMenuRegistry,
@@ -19,6 +18,7 @@ import {
import * as dialog from './dialog.js';
import * as Events from './events/events.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {CommentIcon} from './icons/comment_icon.js';
import {Msg} from './msg.js';
import {StatementInput} from './renderers/zelos/zelos.js';
@@ -614,19 +614,24 @@ export function registerCommentCreate() {
preconditionFn: (scope: Scope) => {
return scope.workspace?.isMutator ? 'hidden' : 'enabled';
},
callback: (scope: Scope, e: PointerEvent) => {
callback: (
scope: Scope,
menuOpenEvent: Event,
menuSelectEvent: Event,
location: Coordinate,
) => {
const workspace = scope.workspace;
if (!workspace) return;
eventUtils.setGroup(true);
const comment = new RenderedWorkspaceComment(workspace);
comment.setText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']);
comment.setPlaceholderText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']);
comment.moveTo(
pixelsToWorkspaceCoords(
new Coordinate(e.clientX, e.clientY),
new Coordinate(location.x, location.y),
workspace,
),
);
common.setSelected(comment);
getFocusManager().focusNode(comment);
eventUtils.setGroup(false);
},
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,

View File

@@ -13,6 +13,8 @@
import type {BlockSvg} from './block_svg.js';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import {Coordinate} from './utils/coordinate.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
@@ -70,39 +72,60 @@ export class ContextMenuRegistry {
}
/**
* Gets the valid context menu options for the given scope type (e.g. block or
* workspace) and scope. Blocks are only shown if the preconditionFn shows
* Gets the valid context menu options for the given scope.
* Options are only included if the preconditionFn shows
* they should not be hidden.
*
* @param scopeType Type of scope where menu should be shown (e.g. on a block
* or on a workspace)
* @param scope Current scope of context menu (i.e., the exact workspace or
* block being clicked on)
* block being clicked on).
* @param menuOpenEvent Event that caused the menu to open.
* @returns the list of ContextMenuOptions
*/
getContextMenuOptions(
scopeType: ScopeType,
scope: Scope,
menuOpenEvent: Event,
): ContextMenuOption[] {
const menuOptions: ContextMenuOption[] = [];
for (const item of this.registeredItems.values()) {
if (scopeType === item.scopeType) {
const precondition = item.preconditionFn(scope);
if (precondition !== 'hidden') {
const displayText =
typeof item.displayText === 'function'
? item.displayText(scope)
: item.displayText;
const menuOption: ContextMenuOption = {
text: displayText,
enabled: precondition === 'enabled',
callback: item.callback,
scope,
weight: item.weight,
};
menuOptions.push(menuOption);
}
if (item.scopeType) {
// If the scopeType is present, check to make sure
// that the option is compatible with the current scope
if (item.scopeType === ScopeType.BLOCK && !scope.block) continue;
if (item.scopeType === ScopeType.COMMENT && !scope.comment) continue;
if (item.scopeType === ScopeType.WORKSPACE && !scope.workspace)
continue;
}
let menuOption:
| ContextMenuRegistry.CoreContextMenuOption
| ContextMenuRegistry.SeparatorContextMenuOption
| ContextMenuRegistry.ActionContextMenuOption;
menuOption = {
scope,
weight: item.weight,
};
if (item.separator) {
menuOption = {
...menuOption,
separator: true,
};
} else {
const precondition = item.preconditionFn(scope, menuOpenEvent);
if (precondition === 'hidden') continue;
const displayText =
typeof item.displayText === 'function'
? item.displayText(scope)
: item.displayText;
menuOption = {
...menuOption,
text: displayText,
callback: item.callback,
enabled: precondition === 'enabled',
};
}
menuOptions.push(menuOption);
}
menuOptions.sort(function (a, b) {
return a.weight - b.weight;
@@ -124,50 +147,110 @@ export namespace ContextMenuRegistry {
}
/**
* The actual workspace/block where the menu is being rendered. This is passed
* to callback and displayText functions that depend on this information.
* The actual workspace/block/focused object where the menu is being
* rendered. This is passed to callback and displayText functions
* that depend on this information.
*/
export interface Scope {
block?: BlockSvg;
workspace?: WorkspaceSvg;
comment?: RenderedWorkspaceComment;
focusedNode?: IFocusableNode;
}
/**
* A menu item as entered in the registry.
* Fields common to all context menu registry items.
*/
export interface RegistryItem {
/**
* @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.
*/
callback: (scope: Scope, e: PointerEvent) => void;
scopeType: ScopeType;
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
preconditionFn: (p1: Scope) => string;
interface CoreRegistryItem {
scopeType?: ScopeType;
weight: number;
id: string;
}
/**
* A menu item as presented to contextmenu.js.
* A representation of a normal, clickable menu item in the registry.
*/
export interface ContextMenuOption {
interface ActionRegistryItem extends CoreRegistryItem {
/**
* @param scope Object that provides a reference to the thing that had its
* context menu opened.
* @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,
menuOpenEvent: Event,
menuSelectEvent: Event,
location: Coordinate,
) => void;
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
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;
}
/**
* A representation of a normal, clickable menu item in contextmenu.ts.
*/
export interface ActionContextMenuOption extends CoreContextMenuOption {
text: string | HTMLElement;
enabled: boolean;
/**
* @param scope Object that provides a reference to the thing that had its
* context menu opened.
* @param e The original event that triggered the context menu to open. Not
* the event that triggered the click on the option.
* @param menuOpenEvent The original event that triggered the context menu to open.
* @param menuSelectEvent The event that triggered the option being selected.
* @param location The location in screen coordinates where the menu was opened.
*/
callback: (scope: Scope, e: PointerEvent) => void;
scope: Scope;
weight: number;
callback: (
scope: Scope,
menuOpenEvent: Event,
menuSelectEvent: Event,
location: Coordinate,
) => void;
separator?: never;
}
/**
* A representation of a menu separator item in contextmenu.ts.
*/
export interface SeparatorContextMenuOption extends CoreContextMenuOption {
separator: true;
text?: never;
enabled?: never;
callback?: never;
}
/**
* A menu item as presented to contextmenu.ts.
*/
export type ContextMenuOption =
| ActionContextMenuOption
| SeparatorContextMenuOption;
/**
* A subset of ContextMenuOption corresponding to what was publicly
* documented. ContextMenuOption should be preferred for new code.
@@ -176,6 +259,7 @@ export namespace ContextMenuRegistry {
text: string;
enabled: boolean;
callback: (p1: Scope) => void;
separator?: never;
}
/**

View File

@@ -5,7 +5,6 @@
*/
// Former goog.module ID: Blockly.Css
/** Has CSS already been injected? */
let injected = false;
@@ -83,17 +82,15 @@ let content = `
-webkit-user-select: none;
}
.blocklyNonSelectable {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.blocklyBlockCanvas.blocklyCanvasTransitioning,
.blocklyBubbleCanvas.blocklyCanvasTransitioning {
transition: transform .5s;
}
.blocklyEmboss {
filter: var(--blocklyEmbossFilter);
}
.blocklyTooltipDiv {
background-color: #ffffc7;
border: 1px solid #ddc;
@@ -121,15 +118,12 @@ let content = `
box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
}
.blocklyDropDownDiv.blocklyFocused {
.blocklyDropDownDiv:focus {
box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
}
.blocklyDropDownContent {
max-height: 300px; /* @todo: spec for maximum height. */
overflow: auto;
overflow-x: hidden;
position: relative;
}
.blocklyDropDownArrow {
@@ -141,47 +135,14 @@ let content = `
z-index: -1;
background-color: inherit;
border-color: inherit;
}
.blocklyDropDownButton {
display: inline-block;
float: left;
padding: 0;
margin: 4px;
border-radius: 4px;
outline: none;
border: 1px solid;
transition: box-shadow .1s;
cursor: pointer;
}
.blocklyArrowTop {
border-top: 1px solid;
border-left: 1px solid;
border-top-left-radius: 4px;
border-color: inherit;
}
.blocklyArrowBottom {
border-bottom: 1px solid;
border-right: 1px solid;
border-bottom-right-radius: 4px;
border-color: inherit;
}
.blocklyResizeSE {
cursor: se-resize;
fill: #aaa;
}
.blocklyResizeSW {
cursor: sw-resize;
fill: #aaa;
}
.blocklyResizeLine {
stroke: #515A5A;
stroke-width: 1;
.blocklyHighlighted>.blocklyPath {
filter: var(--blocklyEmbossFilter);
}
.blocklyHighlightedConnectionPath {
@@ -234,7 +195,8 @@ let content = `
display: none;
}
.blocklyDisabled>.blocklyPath {
.blocklyDisabledPattern>.blocklyPath {
fill: var(--blocklyDisabledPattern);
fill-opacity: .5;
stroke-opacity: .5;
}
@@ -251,7 +213,7 @@ let content = `
stroke: none;
}
.blocklyNonEditableText>text {
.blocklyNonEditableField>text {
pointer-events: none;
}
@@ -264,12 +226,15 @@ let content = `
cursor: default;
}
.blocklyHidden {
display: none;
}
.blocklyFieldDropdown:not(.blocklyHidden) {
display: block;
/*
Don't allow users to select text. It gets annoying when trying to
drag a block and selected text moves instead.
*/
.blocklySvg text {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
cursor: inherit;
}
.blocklyIconGroup {
@@ -419,6 +384,9 @@ input[type=number] {
}
.blocklyWidgetDiv .blocklyMenu {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
background: #fff;
border: 1px solid transparent;
box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
@@ -433,16 +401,21 @@ input[type=number] {
z-index: 20000; /* Arbitrary, but some apps depend on it... */
}
.blocklyWidgetDiv .blocklyMenu.blocklyFocused {
.blocklyWidgetDiv .blocklyMenu:focus {
box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
}
.blocklyDropDownDiv .blocklyMenu {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
background: inherit; /* Compatibility with gapi, reset from goog-menu */
border: inherit; /* Compatibility with gapi, reset from goog-menu */
font: normal 13px "Helvetica Neue", Helvetica, sans-serif;
outline: none;
position: relative; /* Compatibility with gapi, reset from goog-menu */
overflow-y: auto;
overflow-x: hidden;
max-height: 100%;
z-index: 20000; /* Arbitrary, but some apps depend on it... */
}
@@ -489,6 +462,14 @@ input[type=number] {
margin-right: -24px;
}
.blocklyMenuSeparator {
background-color: #ccc;
height: 1px;
border: 0;
margin-left: 4px;
margin-right: 4px;
}
.blocklyBlockDragSurface, .blocklyAnimationLayer {
position: absolute;
top: 0;
@@ -499,4 +480,31 @@ input[type=number] {
z-index: 80;
pointer-events: none;
}
.blocklyField {
cursor: default;
}
.blocklyInputField {
cursor: text;
}
.blocklyDragging .blocklyField,
.blocklyDragging .blocklyIconGroup {
cursor: grabbing;
}
.blocklyActiveFocus:is(
.blocklyFlyout,
.blocklyWorkspace,
.blocklyField,
.blocklyPath,
.blocklyHighlightedConnectionPath,
.blocklyComment,
.blocklyBubble,
.blocklyIconGroup,
.blocklyTextarea
) {
outline-width: 0px;
}
`;

View File

@@ -6,31 +6,43 @@
// Former goog.module ID: Blockly.dialog
let alertImplementation = function (
message: string,
opt_callback?: () => void,
) {
import type {ToastOptions} from './toast.js';
import {Toast} from './toast.js';
import type {WorkspaceSvg} from './workspace_svg.js';
const defaultAlert = function (message: string, opt_callback?: () => void) {
window.alert(message);
if (opt_callback) {
opt_callback();
}
};
let confirmImplementation = function (
let alertImplementation = defaultAlert;
const defaultConfirm = function (
message: string,
callback: (result: boolean) => void,
) {
callback(window.confirm(message));
};
let promptImplementation = function (
let confirmImplementation = defaultConfirm;
const defaultPrompt = function (
message: string,
defaultValue: string,
callback: (result: string | null) => void,
) {
// NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native
// window prompt since it prevents focus from changing while open.
callback(window.prompt(message, defaultValue));
};
let promptImplementation = defaultPrompt;
const defaultToast = Toast.show.bind(Toast);
let toastImplementation = defaultToast;
/**
* Wrapper to window.alert() that app developers may override via setAlert to
* provide alternatives to the modal browser window.
@@ -45,10 +57,16 @@ export function alert(message: string, opt_callback?: () => void) {
/**
* Sets the function to be run when Blockly.dialog.alert() is called.
*
* @param alertFunction The function to be run.
* @param alertFunction The function to be run, or undefined to restore the
* default implementation.
* @see Blockly.dialog.alert
*/
export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {
export function setAlert(
alertFunction: (
message: string,
callback?: () => void,
) => void = defaultAlert,
) {
alertImplementation = alertFunction;
}
@@ -59,25 +77,22 @@ export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {
* @param message The message to display to the user.
* @param callback The callback for handling user response.
*/
export function confirm(message: string, callback: (p1: boolean) => void) {
TEST_ONLY.confirmInternal(message, callback);
}
/**
* Private version of confirm for stubbing in tests.
*/
function confirmInternal(message: string, callback: (p1: boolean) => void) {
export function confirm(message: string, callback: (result: boolean) => void) {
confirmImplementation(message, callback);
}
/**
* Sets the function to be run when Blockly.dialog.confirm() is called.
*
* @param confirmFunction The function to be run.
* @param confirmFunction The function to be run, or undefined to restore the
* default implementation.
* @see Blockly.dialog.confirm
*/
export function setConfirm(
confirmFunction: (p1: string, p2: (p1: boolean) => void) => void,
confirmFunction: (
message: string,
callback: (result: boolean) => void,
) => void = defaultConfirm,
) {
confirmImplementation = confirmFunction;
}
@@ -95,7 +110,7 @@ export function setConfirm(
export function prompt(
message: string,
defaultValue: string,
callback: (p1: string | null) => void,
callback: (result: string | null) => void,
) {
promptImplementation(message, defaultValue, callback);
}
@@ -103,19 +118,50 @@ export function prompt(
/**
* Sets the function to be run when Blockly.dialog.prompt() is called.
*
* @param promptFunction The function to be run.
* **Important**: When overridding this, be aware that non-native prompt
* experiences may require managing ephemeral focus in FocusManager. This isn't
* needed for the native window prompt because it prevents focus from being
* changed while open.
*
* @param promptFunction The function to be run, or undefined to restore the
* default implementation.
* @see Blockly.dialog.prompt
*/
export function setPrompt(
promptFunction: (
p1: string,
p2: string,
p3: (p1: string | null) => void,
) => void,
message: string,
defaultValue: string,
callback: (result: string | null) => void,
) => void = defaultPrompt,
) {
promptImplementation = promptFunction;
}
export const TEST_ONLY = {
confirmInternal,
};
/**
* Displays a temporary notification atop the workspace. Blockly provides a
* default toast implementation, but developers may provide their own via
* setToast. For simple appearance customization, CSS should be sufficient.
*
* @param workspace The workspace to display the toast notification atop.
* @param options Configuration options for the notification, including its
* message and duration.
*/
export function toast(workspace: WorkspaceSvg, options: ToastOptions) {
toastImplementation(workspace, options);
}
/**
* Sets the function to be run when Blockly.dialog.toast() is called.
*
* @param toastFunction The function to be run, or undefined to restore the
* default implementation.
* @see Blockly.dialog.toast
*/
export function setToast(
toastFunction: (
workspace: WorkspaceSvg,
options: ToastOptions,
) => void = defaultToast,
) {
toastImplementation = toastFunction;
}

View File

@@ -62,8 +62,8 @@ export class BlockDragStrategy implements IDragStrategy {
*/
private dragOffset = new Coordinate(0, 0);
/** Was there already an event group in progress when the drag started? */
private inGroup: boolean = false;
/** Used to persist an event group when snapping is done async. */
private originalEventGroup = '';
constructor(private block: BlockSvg) {
this.workspace = block.workspace;
@@ -78,7 +78,7 @@ export class BlockDragStrategy implements IDragStrategy {
return (
this.block.isOwnMovable() &&
!this.block.isDeadOrDying() &&
!this.workspace.options.readOnly &&
!this.workspace.isReadOnly() &&
// We never drag blocks in the flyout, only create new blocks that are
// dragged.
!this.block.isInFlyout
@@ -96,10 +96,6 @@ export class BlockDragStrategy implements IDragStrategy {
}
this.dragging = true;
this.inGroup = !!eventUtils.getGroup();
if (!this.inGroup) {
eventUtils.setGroup(true);
}
this.fireDragStartEvent();
this.startLoc = this.block.getRelativeToSurfaceXY();
@@ -117,7 +113,7 @@ export class BlockDragStrategy implements IDragStrategy {
this.workspace.setResizesEnabled(false);
blockAnimation.disconnectUiStop();
const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey);
const healStack = this.shouldHealStack(e);
if (this.shouldDisconnect(healStack)) {
this.disconnectBlock(healStack);
@@ -126,6 +122,17 @@ export class BlockDragStrategy implements IDragStrategy {
this.workspace.getLayerManager()?.moveToDragLayer(this.block);
}
/**
* Get whether the drag should act on a single block or a block stack.
*
* @param e The instigating pointer event, if any.
* @returns True if just the initial block should be dragged out, false
* if all following blocks should also be dragged.
*/
protected shouldHealStack(e: PointerEvent | undefined) {
return !!e && (e.altKey || e.ctrlKey || e.metaKey);
}
/** Starts a drag on a shadow, recording the drag offset. */
private startDraggingShadow(e?: PointerEvent) {
const parent = this.block.getParent();
@@ -231,7 +238,7 @@ export class BlockDragStrategy implements IDragStrategy {
const currCandidate = this.connectionCandidate;
const newCandidate = this.getConnectionCandidate(draggingBlock, delta);
if (!newCandidate) {
this.connectionPreviewer!.hidePreview();
this.connectionPreviewer?.hidePreview();
this.connectionCandidate = null;
return;
}
@@ -247,7 +254,7 @@ export class BlockDragStrategy implements IDragStrategy {
local.type === ConnectionType.OUTPUT_VALUE ||
local.type === ConnectionType.PREVIOUS_STATEMENT;
const neighbourIsConnectedToRealBlock =
neighbour.isConnected() && !neighbour.targetBlock()!.isInsertionMarker();
neighbour.isConnected() && !neighbour.targetBlock()?.isInsertionMarker();
if (
localIsOutputOrPrevious &&
neighbourIsConnectedToRealBlock &&
@@ -257,14 +264,14 @@ export class BlockDragStrategy implements IDragStrategy {
local.type,
)
) {
this.connectionPreviewer!.previewReplacement(
this.connectionPreviewer?.previewReplacement(
local,
neighbour,
neighbour.targetBlock()!,
);
return;
}
this.connectionPreviewer!.previewConnection(local, neighbour);
this.connectionPreviewer?.previewConnection(local, neighbour);
}
/**
@@ -319,9 +326,7 @@ export class BlockDragStrategy implements IDragStrategy {
delta: Coordinate,
): ConnectionCandidate | null {
const localConns = this.getLocalConnections(draggingBlock);
let radius = this.connectionCandidate
? config.connectingSnapRadius
: config.snapRadius;
let radius = this.getSearchRadius();
let candidate = null;
for (const conn of localConns) {
@@ -339,6 +344,15 @@ export class BlockDragStrategy implements IDragStrategy {
return candidate;
}
/**
* Get the radius to use when searching for a nearby valid connection.
*/
protected getSearchRadius() {
return this.connectionCandidate
? config.connectingSnapRadius
: config.snapRadius;
}
/**
* Returns all of the connections we might connect to blocks on the workspace.
*
@@ -363,6 +377,7 @@ export class BlockDragStrategy implements IDragStrategy {
this.block.getParent()?.endDrag(e);
return;
}
this.originalEventGroup = eventUtils.getGroup();
this.fireDragEndEvent();
this.fireMoveEvent();
@@ -370,7 +385,7 @@ export class BlockDragStrategy implements IDragStrategy {
dom.stopTextWidthCache();
blockAnimation.disconnectUiStop();
this.connectionPreviewer!.hidePreview();
this.connectionPreviewer?.hidePreview();
if (!this.block.isDeadOrDying() && this.dragging) {
// These are expensive and don't need to be done if we're deleting, or
@@ -388,20 +403,19 @@ export class BlockDragStrategy implements IDragStrategy {
} else {
this.block.queueRender().then(() => this.disposeStep());
}
if (!this.inGroup) {
eventUtils.setGroup(false);
}
}
/** Disposes of any state at the end of the drag. */
private disposeStep() {
const newGroup = eventUtils.getGroup();
eventUtils.setGroup(this.originalEventGroup);
this.block.snapToGrid();
// Must dispose after connections are applied to not break the dynamic
// connections plugin. See #7859
this.connectionPreviewer!.dispose();
this.connectionPreviewer?.dispose();
this.workspace.setResizesEnabled(true);
eventUtils.setGroup(newGroup);
}
/** Connects the given candidate connections. */
@@ -431,6 +445,9 @@ export class BlockDragStrategy implements IDragStrategy {
return;
}
this.connectionPreviewer?.hidePreview();
this.connectionCandidate = null;
this.startChildConn?.connect(this.block.nextConnection);
if (this.startParentConn) {
switch (this.startParentConn.type) {
@@ -457,9 +474,6 @@ export class BlockDragStrategy implements IDragStrategy {
this.startChildConn = null;
this.startParentConn = null;
this.connectionPreviewer!.hidePreview();
this.connectionCandidate = null;
this.block.setDragging(false);
this.dragging = false;
}

View File

@@ -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()

View File

@@ -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. */

View File

@@ -8,11 +8,13 @@ import * as blockAnimations from '../block_animations.js';
import {BlockSvg} from '../block_svg.js';
import {ComponentManager} from '../component_manager.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {IDeletable, isDeletable} from '../interfaces/i_deletable.js';
import {IDeleteArea} from '../interfaces/i_delete_area.js';
import {IDragTarget} from '../interfaces/i_drag_target.js';
import {IDraggable} from '../interfaces/i_draggable.js';
import {IDragger} from '../interfaces/i_dragger.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -31,6 +33,9 @@ export class Dragger implements IDragger {
/** Handles any drag startup. */
onDragStart(e: PointerEvent) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.draggable.startDrag(e);
}
@@ -119,12 +124,18 @@ export class Dragger implements IDragger {
this.draggable.endDrag(e);
if (wouldDelete && isDeletable(root)) {
// We want to make sure the delete gets grouped with any possible
// move event.
const newGroup = eventUtils.getGroup();
// We want to make sure the delete gets grouped with any possible move
// event. In core Blockly this shouldn't happen, but due to a change
// in behavior older custom draggables might still clear the group.
eventUtils.setGroup(origGroup);
root.dispose();
eventUtils.setGroup(newGroup);
}
eventUtils.setGroup(false);
if (!wouldDelete && isFocusableNode(this.draggable)) {
// Ensure focusable nodes that have finished dragging (but aren't being
// deleted) end with focus and selection.
getFocusManager().focusNode(this.draggable);
}
}

View File

@@ -15,6 +15,7 @@
import type {BlockSvg} from './block_svg.js';
import * as common from './common.js';
import type {Field} from './field.js';
import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js';
import * as dom from './utils/dom.js';
import * as math from './utils/math.js';
import {Rect} from './utils/rect.js';
@@ -82,6 +83,9 @@ let owner: Field | null = null;
/** Whether the dropdown was positioned to a field or the source block. */
let positionToField: boolean | null = null;
/** Callback to FocusManager to return ephemeral focus when the div closes. */
let returnEphemeralFocus: ReturnEphemeralFocus | null = null;
/**
* Dropdown bounds info object used to encapsulate sizing information about a
* bounding element (bounding box and width/height).
@@ -133,15 +137,6 @@ export function createDom() {
// Transition animation for transform: translate() and opacity.
div.style.transition =
'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's';
// Handle focusin/out events to add a visual indicator when
// a child is focused or blurred.
div.addEventListener('focusin', function () {
dom.addClass(div, 'blocklyFocused');
});
div.addEventListener('focusout', function () {
dom.removeClass(div, 'blocklyFocused');
});
}
/**
@@ -166,14 +161,14 @@ export function getOwner(): Field | null {
*
* @returns Div to populate with content.
*/
export function getContentDiv(): Element {
export function getContentDiv(): HTMLDivElement {
return content;
}
/** Clear the content of the drop-down. */
export function clearContent() {
content.textContent = '';
content.style.width = '';
div.remove();
createDom();
}
/**
@@ -273,6 +268,11 @@ function getScaledBboxOfField(field: Field): Rect {
* @param field The field to position the dropdown against.
* @param opt_onHide Optional callback for when the drop-down is hidden.
* @param opt_secondaryYOffset Optional Y offset for above-block positioning.
* @param manageEphemeralFocus Whether ephemeral focus should be managed
* according to the drop-down div's lifetime. Note that if a false value is
* passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults
* to true.
* @returns True if the menu rendered below block; false if above.
*/
function showPositionedByRect(
@@ -280,6 +280,7 @@ function showPositionedByRect(
field: Field,
opt_onHide?: () => void,
opt_secondaryYOffset?: number,
manageEphemeralFocus: boolean = true,
): boolean {
// If we can fit it, render below the block.
const primaryX = bBox.left + (bBox.right - bBox.left) / 2;
@@ -304,6 +305,7 @@ function showPositionedByRect(
primaryY,
secondaryX,
secondaryY,
manageEphemeralFocus,
opt_onHide,
);
}
@@ -324,6 +326,8 @@ function showPositionedByRect(
* @param secondaryX Secondary/alternative origin point x, in absolute px.
* @param secondaryY Secondary/alternative origin point y, in absolute px.
* @param opt_onHide Optional callback for when the drop-down is hidden.
* @param manageEphemeralFocus Whether ephemeral focus should be managed
* according to the widget div's lifetime.
* @returns True if the menu rendered at the primary origin point.
* @internal
*/
@@ -334,6 +338,7 @@ export function show<T>(
primaryY: number,
secondaryX: number,
secondaryY: number,
manageEphemeralFocus: boolean,
opt_onHide?: () => void,
): boolean {
owner = newOwner as Field;
@@ -344,11 +349,11 @@ export function show<T>(
const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg;
renderedClassName = mainWorkspace.getRenderer().getClassName();
themeClassName = mainWorkspace.getTheme().getClassName();
if (renderedClassName) {
dom.addClass(div, renderedClassName);
}
if (themeClassName) {
dom.addClass(div, themeClassName);
dom.addClass(div, renderedClassName);
dom.addClass(div, themeClassName);
if (manageEphemeralFocus) {
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
}
// When we change `translate` multiple times in close succession,
@@ -651,16 +656,6 @@ export function hideWithoutAnimation() {
clearTimeout(animateOutTimer);
}
// Reset style properties in case this gets called directly
// instead of hide() - see discussion on #2551.
div.style.transform = '';
div.style.left = '';
div.style.top = '';
div.style.opacity = '0';
div.style.display = 'none';
div.style.backgroundColor = '';
div.style.borderColor = '';
if (onHide) {
onHide();
onHide = null;
@@ -668,15 +663,12 @@ export function hideWithoutAnimation() {
clearContent();
owner = null;
if (renderedClassName) {
dom.removeClass(div, renderedClassName);
renderedClassName = '';
}
if (themeClassName) {
dom.removeClass(div, themeClassName);
themeClassName = '';
}
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
}
/**
@@ -703,19 +695,12 @@ function positionInternal(
// Update arrow CSS.
if (metrics.arrowVisible) {
const x = metrics.arrowX;
const y = metrics.arrowY;
const rotation = metrics.arrowAtTop ? 45 : 225;
arrow.style.display = '';
arrow.style.transform =
'translate(' +
metrics.arrowX +
'px,' +
metrics.arrowY +
'px) rotate(45deg)';
arrow.setAttribute(
'class',
metrics.arrowAtTop
? 'blocklyDropDownArrow blocklyArrowTop'
: 'blocklyDropDownArrow blocklyArrowBottom',
);
arrow.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
arrow.setAttribute('class', 'blocklyDropDownArrow');
} else {
arrow.style.display = 'none';
}

View File

@@ -33,19 +33,21 @@ export {CommentDelete} from './events_comment_delete.js';
export {CommentDrag, CommentDragJson} from './events_comment_drag.js';
export {CommentMove, CommentMoveJson} from './events_comment_move.js';
export {CommentResize, CommentResizeJson} from './events_comment_resize.js';
export {MarkerMove, MarkerMoveJson} from './events_marker_move.js';
export {Selected, SelectedJson} from './events_selected.js';
export {ThemeChange, ThemeChangeJson} from './events_theme_change.js';
export {
ToolboxItemSelect,
ToolboxItemSelectJson,
} from './events_toolbox_item_select.js';
// Events.
export {TrashcanOpen, TrashcanOpenJson} from './events_trashcan_open.js';
export {UiBase} from './events_ui_base.js';
export {VarBase, VarBaseJson} from './events_var_base.js';
export {VarCreate, VarCreateJson} from './events_var_create.js';
export {VarDelete, VarDeleteJson} from './events_var_delete.js';
export {VarRename, VarRenameJson} from './events_var_rename.js';
export {VarTypeChange, VarTypeChangeJson} from './events_var_type_change.js';
export {ViewportChange, ViewportChangeJson} from './events_viewport.js';
export {FinishedLoading} from './workspace_events.js';
@@ -74,7 +76,6 @@ export const CREATE = EventType.BLOCK_CREATE;
/** @deprecated Use BLOCK_DELETE instead */
export const DELETE = EventType.BLOCK_DELETE;
export const FINISHED_LOADING = EventType.FINISHED_LOADING;
export const MARKER_MOVE = EventType.MARKER_MOVE;
/** @deprecated Use BLOCK_MOVE instead */
export const MOVE = EventType.BLOCK_MOVE;
export const SELECTED = EventType.SELECTED;

View File

@@ -1,133 +0,0 @@
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Events fired as a result of a marker move.
*
* @class
*/
// Former goog.module ID: Blockly.Events.MarkerMove
import type {Block} from '../block.js';
import {ASTNode} from '../keyboard_nav/ast_node.js';
import * as registry from '../registry.js';
import type {Workspace} from '../workspace.js';
import {AbstractEventJson} from './events_abstract.js';
import {UiBase} from './events_ui_base.js';
import {EventType} from './type.js';
/**
* Notifies listeners that a marker (used for keyboard navigation) has
* moved.
*/
export class MarkerMove extends UiBase {
/** The ID of the block the marker is now on, if any. */
blockId?: string;
/** The old node the marker used to be on, if any. */
oldNode?: ASTNode;
/** The new node the marker is now on. */
newNode?: ASTNode;
/**
* True if this is a cursor event, false otherwise.
* For information about cursors vs markers see {@link
* https://blocklycodelabs.dev/codelabs/keyboard-navigation/index.html?index=..%2F..index#1}.
*/
isCursor?: boolean;
override type = EventType.MARKER_MOVE;
/**
* @param opt_block The affected block. Null if current node is of type
* workspace. Undefined for a blank event.
* @param isCursor Whether this is a cursor event. Undefined for a blank
* event.
* @param opt_oldNode The old node the marker used to be on.
* Undefined for a blank event.
* @param opt_newNode The new node the marker is now on.
* Undefined for a blank event.
*/
constructor(
opt_block?: Block | null,
isCursor?: boolean,
opt_oldNode?: ASTNode | null,
opt_newNode?: ASTNode,
) {
let workspaceId = opt_block ? opt_block.workspace.id : undefined;
if (opt_newNode && opt_newNode.getType() === ASTNode.types.WORKSPACE) {
workspaceId = (opt_newNode.getLocation() as Workspace).id;
}
super(workspaceId);
this.blockId = opt_block?.id;
this.oldNode = opt_oldNode || undefined;
this.newNode = opt_newNode;
this.isCursor = isCursor;
}
/**
* Encode the event as JSON.
*
* @returns JSON representation.
*/
override toJson(): MarkerMoveJson {
const json = super.toJson() as MarkerMoveJson;
if (this.isCursor === undefined) {
throw new Error(
'Whether this is a cursor event or not is undefined. Either pass ' +
'a value to the constructor, or call fromJson',
);
}
if (!this.newNode) {
throw new Error(
'The new node is undefined. Either pass a node to ' +
'the constructor, or call fromJson',
);
}
json['isCursor'] = this.isCursor;
json['blockId'] = this.blockId;
json['oldNode'] = this.oldNode;
json['newNode'] = this.newNode;
return json;
}
/**
* Deserializes the JSON event.
*
* @param event The event to append new properties to. Should be a subclass
* of MarkerMove, but we can't specify that due to the fact that
* parameters to static methods in subclasses must be supertypes of
* parameters to static methods in superclasses.
* @internal
*/
static fromJson(
json: MarkerMoveJson,
workspace: Workspace,
event?: any,
): MarkerMove {
const newEvent = super.fromJson(
json,
workspace,
event ?? new MarkerMove(),
) as MarkerMove;
newEvent.isCursor = json['isCursor'];
newEvent.blockId = json['blockId'];
newEvent.oldNode = json['oldNode'];
newEvent.newNode = json['newNode'];
return newEvent;
}
}
export interface MarkerMoveJson extends AbstractEventJson {
isCursor: boolean;
blockId?: string;
oldNode?: ASTNode;
newNode: ASTNode;
}
registry.register(registry.Type.EVENT, EventType.MARKER_MOVE, MarkerMove);

View File

@@ -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<IVariableState>) {
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;
}
/**

View File

@@ -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<IVariableState>) {
super(opt_variable);
if (!opt_variable) {
return; // Blank event to be populated by fromJson.
}
this.varType = opt_variable.type;
this.varName = opt_variable.name;
this.varType = opt_variable.getType();
this.varName = opt_variable.getName();
}
/**
@@ -109,10 +113,12 @@ export class VarCreate extends VarBase {
'the constructor, or call fromJson',
);
}
const variableMap = workspace.getVariableMap();
if (forward) {
workspace.createVariable(this.varName, this.varType, this.varId);
variableMap.createVariable(this.varName, this.varType, this.varId);
} else {
workspace.deleteVariableById(this.varId);
const variable = variableMap.getVariableById(this.varId);
if (variable) variableMap.deleteVariable(variable);
}
}
}

View File

@@ -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<IVariableState>) {
super(opt_variable);
if (!opt_variable) {
return; // Blank event to be populated by fromJson.
}
this.varType = opt_variable.type;
this.varName = opt_variable.name;
this.varType = opt_variable.getType();
this.varName = opt_variable.getName();
}
/**
@@ -104,10 +106,12 @@ export class VarDelete extends VarBase {
'the constructor, or call fromJson',
);
}
const variableMap = workspace.getVariableMap();
if (forward) {
workspace.deleteVariableById(this.varId);
const variable = variableMap.getVariableById(this.varId);
if (variable) variableMap.deleteVariable(variable);
} else {
workspace.createVariable(this.varName, this.varType, this.varId);
variableMap.createVariable(this.varName, this.varType, this.varId);
}
}
}

View File

@@ -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<IVariableState>, newName?: string) {
super(opt_variable);
if (!opt_variable) {
return; // Blank event to be populated by fromJson.
}
this.oldName = opt_variable.name;
this.oldName = opt_variable.getName();
this.newName = typeof newName === 'undefined' ? '' : newName;
}
@@ -113,10 +115,12 @@ export class VarRename extends VarBase {
'the constructor, or call fromJson',
);
}
const variableMap = workspace.getVariableMap();
const variable = variableMap.getVariableById(this.varId);
if (forward) {
workspace.renameVariableById(this.varId, this.newName);
if (variable) variableMap.renameVariable(variable, this.newName);
} else {
workspace.renameVariableById(this.varId, this.oldName);
if (variable) variableMap.renameVariable(variable, this.oldName);
}
}
}

View File

@@ -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<IVariableState>,
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,
);

View File

@@ -29,7 +29,6 @@ import type {CommentDelete} from './events_comment_delete.js';
import type {CommentDrag} from './events_comment_drag.js';
import type {CommentMove} from './events_comment_move.js';
import type {CommentResize} from './events_comment_resize.js';
import type {MarkerMove} from './events_marker_move.js';
import type {Selected} from './events_selected.js';
import type {ThemeChange} from './events_theme_change.js';
import type {ToolboxItemSelect} from './events_toolbox_item_select.js';
@@ -99,11 +98,6 @@ export function isClick(event: Abstract): event is Click {
return event.type === EventType.CLICK;
}
/** @returns true iff event.type is EventType.MARKER_MOVE */
export function isMarkerMove(event: Abstract): event is MarkerMove {
return event.type === EventType.MARKER_MOVE;
}
/** @returns true iff event.type is EventType.BUBBLE_OPEN */
export function isBubbleOpen(event: Abstract): event is BubbleOpen {
return event.type === EventType.BUBBLE_OPEN;

View File

@@ -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.
*

View File

@@ -9,7 +9,6 @@
import type {Block} from '../block.js';
import * as common from '../common.js';
import * as registry from '../registry.js';
import * as deprecation from '../utils/deprecation.js';
import * as idGenerator from '../utils/idgenerator.js';
import type {Workspace} from '../workspace.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -124,7 +123,7 @@ function fireInternal(event: Abstract) {
/** Dispatch all queued events. */
function fireNow() {
const queue = filter(FIRE_QUEUE, true);
const queue = filter(FIRE_QUEUE);
FIRE_QUEUE.length = 0;
for (const event of queue) {
if (!event.workspaceId) continue;
@@ -227,18 +226,9 @@ function enqueueEvent(event: Abstract) {
* cause them to be reordered.
*
* @param queue Array of events.
* @param forward True if forward (redo), false if backward (undo).
* This parameter is deprecated: true is now the default and
* calling filter with it set to false will in future not be
* supported.
* @returns Array of filtered events.
*/
export function filter(queue: Abstract[], forward = true): Abstract[] {
if (!forward) {
deprecation.warn('filter(queue, /*forward=*/false)', 'v11.2', 'v12');
// Undo was merged in reverse order.
queue = queue.slice().reverse(); // Copy before reversing in place.
}
export function filter(queue: Abstract[]): Abstract[] {
const mergedQueue: Abstract[] = [];
// Merge duplicates.
for (const event of queue) {
@@ -290,10 +280,6 @@ export function filter(queue: Abstract[], forward = true): Abstract[] {
}
// Filter out any events that have become null due to merging.
queue = mergedQueue.filter((e) => !e.isNull());
if (!forward) {
// Restore undo order.
queue.reverse();
}
return queue;
}

View File

@@ -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 ` +

View File

@@ -23,17 +23,17 @@ import * as dropDownDiv from './dropdowndiv.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import type {Input} from './inputs/input.js';
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
import type {IRegistrable} from './interfaces/i_registrable.js';
import {ISerializable} from './interfaces/i_serializable.js';
import {MarkerManager} from './marker_manager.js';
import type {ConstantProvider} from './renderers/common/constants.js';
import type {KeyboardShortcut} from './shortcut_registry.js';
import * as Tooltip from './tooltip.js';
import type {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
import * as parsing from './utils/parsing.js';
import {Rect} from './utils/rect.js';
import {Size} from './utils/size.js';
@@ -42,7 +42,7 @@ import {Svg} from './utils/svg.js';
import * as userAgent from './utils/useragent.js';
import * as utilsXml from './utils/xml.js';
import * as WidgetDiv from './widgetdiv.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import {WorkspaceSvg} from './workspace_svg.js';
/**
* A function that is called to validate changes to the field's value before
@@ -67,12 +67,7 @@ export type FieldValidator<T = any> = (newValue: T) => T | null | undefined;
* @typeParam T - The value stored on the field.
*/
export abstract class Field<T = any>
implements
IASTNodeLocationSvg,
IASTNodeLocationWithBlock,
IKeyboardAccessible,
IRegistrable,
ISerializable
implements IKeyboardAccessible, IRegistrable, ISerializable, IFocusableNode
{
/**
* To overwrite the default value which is set in **Field**, directly update
@@ -108,19 +103,28 @@ export abstract class Field<T = any>
* field is not yet initialized. Is *not* guaranteed to be accurate.
*/
private tooltip: Tooltip.TipInfo | null = null;
protected size_: Size;
/** This field's dimensions. */
private size: Size = new Size(0, 0);
/**
* Holds the cursors svg element when the cursor is attached to the field.
* This is null if there is no cursor on the field.
* Gets the size of this field. Because getSize() and updateSize() have side
* effects, this acts as a shim for subclasses which wish to adjust field
* bounds when setting/getting the size without triggering unwanted rendering
* or other side effects. Note that subclasses must override *both* get and
* set if either is overridden; the implementation may just call directly
* through to super, but it must exist per the JS spec.
*/
private cursorSvg: SVGElement | null = null;
protected get size_(): Size {
return this.size;
}
/**
* Holds the markers svg element when the marker is attached to the field.
* This is null if there is no marker on the field.
* Sets the size of this field.
*/
private markerSvg: SVGElement | null = null;
protected set size_(newValue: Size) {
this.size = newValue;
}
/** The rendered field's SVG group element. */
protected fieldGroup_: SVGGElement | null = null;
@@ -194,8 +198,8 @@ export abstract class Field<T = any>
*/
SERIALIZABLE = false;
/** Mouse cursor style when over the hotspot that initiates the editor. */
CURSOR = '';
/** The unique ID of this field. */
private id_: string | null = null;
/**
* @param value The initial value of the field.
@@ -261,6 +265,7 @@ export abstract class Field<T = any>
throw Error('Field already bound to a block');
}
this.sourceBlock_ = block;
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}
/**
@@ -304,7 +309,12 @@ export abstract class Field<T = any>
// Field has already been initialized once.
return;
}
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
const id = this.id_;
if (!id) throw new Error('Expected ID to be defined prior to init.');
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
'tabindex': '-1',
'id': id,
});
if (!this.isVisible()) {
this.fieldGroup_.style.display = 'none';
}
@@ -324,6 +334,9 @@ export abstract class Field<T = any>
protected initView() {
this.createBorderRect_();
this.createTextElement_();
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyField');
}
}
/**
@@ -339,8 +352,10 @@ export abstract class Field<T = any>
* intend because the behavior was kind of hacked in. If you are thinking
* about overriding this function, post on the forum with your intended
* behavior to see if there's another approach.
*
* @internal
*/
protected isFullBlockField(): boolean {
isFullBlockField(): boolean {
return !this.borderRect_;
}
@@ -374,7 +389,7 @@ export abstract class Field<T = any>
this.textElement_ = dom.createSvgElement(
Svg.TEXT,
{
'class': 'blocklyText',
'class': 'blocklyText blocklyFieldText',
},
this.fieldGroup_,
);
@@ -406,7 +421,6 @@ export abstract class Field<T = any>
* called by Blockly.Xml.
*
* @param fieldElement The element containing info about the field's state.
* @internal
*/
fromXml(fieldElement: Element) {
// Any because gremlins live here. No touchie!
@@ -419,7 +433,6 @@ export abstract class Field<T = any>
* @param fieldElement The element to populate with info about the field's
* state.
* @returns The element containing info about the field's state.
* @internal
*/
toXml(fieldElement: Element): Element {
// Any because gremlins live here. No touchie!
@@ -438,7 +451,6 @@ export abstract class Field<T = any>
* {@link https://developers.devsite.google.com/blockly/guides/create-custom-blocks/fields/customizing-fields/creating#full_serialization_and_backing_data | field serialization docs}
* for more information.
* @returns JSON serializable state.
* @internal
*/
saveState(_doFullSerialization?: boolean): AnyDuringMigration {
const legacyState = this.saveLegacyState(Field);
@@ -453,7 +465,6 @@ export abstract class Field<T = any>
* called by the serialization system.
*
* @param state The state we want to apply to the field.
* @internal
*/
loadState(state: AnyDuringMigration) {
if (this.loadLegacyState(Field, state)) {
@@ -516,8 +527,6 @@ export abstract class Field<T = any>
/**
* Dispose of all DOM objects and events belonging to this editable field.
*
* @internal
*/
dispose() {
dropDownDiv.hideIfOwner(this);
@@ -538,13 +547,11 @@ export abstract class Field<T = any>
return;
}
if (this.enabled_ && block.isEditable()) {
dom.addClass(group, 'blocklyEditableText');
dom.removeClass(group, 'blocklyNonEditableText');
group.style.cursor = this.CURSOR;
dom.addClass(group, 'blocklyEditableField');
dom.removeClass(group, 'blocklyNonEditableField');
} else {
dom.addClass(group, 'blocklyNonEditableText');
dom.removeClass(group, 'blocklyEditableText');
group.style.cursor = '';
dom.addClass(group, 'blocklyNonEditableField');
dom.removeClass(group, 'blocklyEditableField');
}
}
@@ -833,12 +840,7 @@ export abstract class Field<T = any>
let contentWidth = 0;
if (this.textElement_) {
contentWidth = dom.getFastTextWidth(
this.textElement_,
constants!.FIELD_TEXT_FONTSIZE,
constants!.FIELD_TEXT_FONTWEIGHT,
constants!.FIELD_TEXT_FONTFAMILY,
);
contentWidth = dom.getTextWidth(this.textElement_);
totalWidth += contentWidth;
}
if (!this.isFullBlockField()) {
@@ -918,17 +920,6 @@ export abstract class Field<T = any>
if (this.isDirty_) {
this.render_();
this.isDirty_ = false;
} else if (this.visible_ && this.size_.width === 0) {
// If the field is not visible the width will be 0 as well, one of the
// problems with the old system.
this.render_();
// Don't issue a warning if the field is actually zero width.
if (this.size_.width !== 0) {
console.warn(
'Deprecated use of setting size_.width to 0 to rerender a' +
' field. Set field.isDirty_ to true instead.',
);
}
}
return this.size_;
}
@@ -992,10 +983,6 @@ export abstract class Field<T = any>
*/
protected getDisplayText_(): string {
let text = this.getText();
if (!text) {
// Prevent the field from disappearing if empty.
return Field.NBSP;
}
if (text.length > this.maxDisplayLength) {
// Truncate displayed string and add an ellipsis ('...').
text = text.substring(0, this.maxDisplayLength - 2) + '…';
@@ -1057,8 +1044,6 @@ export abstract class Field<T = any>
* rerender this field and adjust for any sizing changes.
* Other fields on the same block will not rerender, because their sizes have
* already been recorded.
*
* @internal
*/
forceRerender() {
this.isDirty_ = true;
@@ -1317,7 +1302,6 @@ export abstract class Field<T = any>
* Subclasses may override this.
*
* @returns True if this field has any variable references.
* @internal
*/
referencesVariables(): boolean {
return false;
@@ -1326,8 +1310,6 @@ export abstract class Field<T = any>
/**
* Refresh the variable name referenced by this field if this field references
* variables.
*
* @internal
*/
refreshVariableName() {}
// NOP
@@ -1369,15 +1351,6 @@ export abstract class Field<T = any>
return false;
}
/**
* Returns whether or not the field is tab navigable.
*
* @returns True if the field is tab navigable.
*/
isTabNavigable(): boolean {
return false;
}
/**
* Handles the given keyboard shortcut.
*
@@ -1388,62 +1361,32 @@ export abstract class Field<T = any>
return false;
}
/**
* Add the cursor SVG to this fields SVG group.
*
* @param cursorSvg The SVG root of the cursor to be added to the field group.
* @internal
*/
setCursorSvg(cursorSvg: SVGElement) {
if (!cursorSvg) {
this.cursorSvg = null;
return;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
if (!this.fieldGroup_) {
throw new Error(`The field group is ${this.fieldGroup_}.`);
throw Error('This field currently has no representative DOM element.');
}
this.fieldGroup_.appendChild(cursorSvg);
this.cursorSvg = cursorSvg;
return this.fieldGroup_;
}
/**
* Add the marker SVG to this fields SVG group.
*
* @param markerSvg The SVG root of the marker to be added to the field group.
* @internal
*/
setMarkerSvg(markerSvg: SVGElement) {
if (!markerSvg) {
this.markerSvg = null;
return;
}
if (!this.fieldGroup_) {
throw new Error(`The field group is ${this.fieldGroup_}.`);
}
this.fieldGroup_.appendChild(markerSvg);
this.markerSvg = markerSvg;
}
/**
* Redraw any attached marker or cursor svgs if needed.
*
* @internal
*/
updateMarkers_() {
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
const workspace = block.workspace as WorkspaceSvg;
if (workspace.keyboardAccessibilityMode && this.cursorSvg) {
workspace.getCursor()!.draw();
}
if (workspace.keyboardAccessibilityMode && this.markerSvg) {
// TODO(#4592): Update all markers on the field.
workspace.getMarker(MarkerManager.LOCAL_MARKER)!.draw();
}
return block.workspace as WorkspaceSvg;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
/**

View File

@@ -35,11 +35,6 @@ export class FieldCheckbox extends Field<CheckboxBool> {
*/
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<CheckboxBool> {
super.initView();
const textElement = this.getTextElement();
dom.addClass(textElement, 'blocklyCheckbox');
dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField');
textElement.style.display = this.value_ ? 'block' : 'none';
}

View File

@@ -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<string> {
/** 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<string> {
*/
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. */
@@ -136,26 +129,11 @@ export class FieldDropdown extends Field<string> {
// If we pass SKIP_SETUP, don't do *anything* with the menu generator.
if (menuGenerator === Field.SKIP_SETUP) return;
if (Array.isArray(menuGenerator)) {
this.validateOptions(menuGenerator);
const trimmed = this.trimOptions(menuGenerator);
this.menuGenerator_ = trimmed.options;
this.prefixField = trimmed.prefix || null;
this.suffixField = trimmed.suffix || null;
} else {
this.menuGenerator_ = menuGenerator;
}
/**
* The currently selected option. The field is initialized with the
* first option selected.
*/
this.selectedOption = this.getOptions(false)[0];
this.setOptions(menuGenerator);
if (config) {
this.configure_(config);
}
this.setValue(this.selectedOption[1]);
if (validator) {
this.setValidator(validator);
}
@@ -213,6 +191,11 @@ export class FieldDropdown extends Field<string> {
if (this.borderRect_) {
dom.addClass(this.borderRect_, 'blocklyDropdownRect');
}
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyField');
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
}
}
/**
@@ -277,16 +260,18 @@ export class FieldDropdown extends Field<string> {
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 +282,15 @@ export class FieldDropdown extends Field<string> {
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 +309,19 @@ export class FieldDropdown extends Field<string> {
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;
@@ -414,6 +402,28 @@ export class FieldDropdown extends Field<string> {
return this.generatedOptions;
}
/**
* Update the options on this dropdown. This will reset the selected item to
* the first item in the list.
*
* @param menuGenerator The array of options or a generator function.
*/
setOptions(menuGenerator: MenuGenerator) {
if (Array.isArray(menuGenerator)) {
this.validateOptions(menuGenerator);
const trimmed = this.trimOptions(menuGenerator);
this.menuGenerator_ = trimmed.options;
this.prefixField = trimmed.prefix || null;
this.suffixField = trimmed.suffix || null;
} else {
this.menuGenerator_ = menuGenerator;
}
// The currently selected option. The field is initialized with the
// first option selected.
this.selectedOption = this.getOptions(false)[0];
this.setValue(this.selectedOption[1]);
}
/**
* Ensure that the input value is a valid language-neutral option.
*
@@ -494,7 +504,7 @@ export class FieldDropdown extends Field<string> {
// 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 +551,7 @@ export class FieldDropdown extends Field<string> {
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 +584,7 @@ export class FieldDropdown extends Field<string> {
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;
@@ -633,7 +633,13 @@ export class FieldDropdown extends Field<string> {
/**
* Use the `getText_` developer hook to override the field's text
* representation. Get the selected option text. If the selected option is
* an image we return the image alt text.
* an image we return the image alt text. If the selected option is
* an HTMLElement, return the title, ariaLabel, or innerText of the
* element.
*
* If you use HTMLElement options in Node.js and call this function,
* ensure that you are supplying an implementation of HTMLElement,
* such as through jsdom-global.
*
* @returns Selected option text.
*/
@@ -642,10 +648,23 @@ export class FieldDropdown extends Field<string> {
return null;
}
const option = this.selectedOption[0];
if (typeof option === 'object') {
return option['alt'];
if (isImageProperties(option)) {
return option.alt;
} else if (
typeof HTMLElement !== 'undefined' &&
option instanceof HTMLElement
) {
return option.title ?? option.ariaLabel ?? option.innerText;
} else if (typeof option === 'string') {
return option;
}
return option;
console.warn(
"Can't get text for existing dropdown option. If " +
"you're using HTMLElement dropdown options in node, ensure you're " +
'using jsdom-global or similar.',
);
return null;
}
/**
@@ -681,7 +700,10 @@ export class FieldDropdown extends Field<string> {
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 +711,9 @@ export class FieldDropdown extends Field<string> {
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 +783,31 @@ export class FieldDropdown extends Field<string> {
}
let foundError = false;
for (let i = 0; i < options.length; i++) {
const tuple = options[i];
if (!Array.isArray(tuple)) {
const option = options[i];
if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must be an array.
Found: ${tuple}`,
`Invalid option[${i}]: Each FieldDropdown option must be an array or
the string literal 'separator'. Found: ${option}`,
);
} else if (typeof tuple[1] !== 'string') {
} else if (typeof option[1] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${tuple[1]} in: ${tuple}`,
Found ${option[1]} in: ${option}`,
);
} else if (
tuple[0] &&
typeof tuple[0] !== 'string' &&
typeof tuple[0].src !== 'string'
option[0] &&
typeof option[0] !== 'string' &&
!isImageProperties(option[0]) &&
!(
typeof HTMLElement !== 'undefined' && option[0] instanceof HTMLElement
)
) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string
label or image description. Found ${tuple[0]} in: ${tuple}`,
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
);
}
}
@@ -793,6 +817,27 @@ export class FieldDropdown extends Field<string> {
}
}
/**
* 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 +849,15 @@ export interface ImageProperties {
}
/**
* An individual option in the dropdown menu. The first element is the human-
* readable value (text or image), and the second element is the language-
* neutral value.
* An individual option in the dropdown menu. Can be either the string literal
* `separator` for a menu separator item, or an array for normal action menu
* items. In the latter case, the first element is the human-readable value
* (text, ImageProperties object, or HTML element), and the second element is
* the language-neutral value.
*/
export type MenuOption = [string | ImageProperties, string];
export type MenuOption =
| [string | ImageProperties | HTMLElement, string]
| 'separator';
/**
* A function that generates an array of menu options for FieldDropdown

View File

@@ -27,7 +27,6 @@ export class FieldImage extends Field<string> {
* of the field.
*/
private static readonly Y_PADDING = 1;
protected override size_: Size;
protected readonly imageHeight: number;
/** The function to be called when this field is clicked. */
@@ -151,6 +150,10 @@ export class FieldImage extends Field<string> {
this.value_ as string,
);
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyImageField');
}
if (this.clickHandler) {
this.imageElement.style.cursor = 'pointer';
}

View File

@@ -27,6 +27,7 @@ import {
FieldValidator,
UnattachedFieldError,
} from './field.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
import * as aria from './utils/aria.js';
@@ -100,8 +101,25 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
override SERIALIZABLE = true;
/** Mouse cursor style when over the hotspot that initiates the editor. */
override CURSOR = 'text';
/**
* Sets the size of this field. Although this appears to be a no-op, it must
* exist since the getter is overridden below.
*/
protected override set size_(newValue: Size) {
super.size_ = newValue;
}
/**
* Returns the size of this field, with a minimum width of 14.
*/
protected override get size_() {
const s = super.size_;
if (s.width < 14) {
s.width = 14;
}
return s;
}
/**
* @param value The initial value of the field. Should cast to a string.
@@ -149,9 +167,13 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (this.isFullBlockField()) {
this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();
}
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyInputField');
}
}
protected override isFullBlockField(): boolean {
override isFullBlockField(): boolean {
const block = this.getSourceBlock();
if (!block) throw new UnattachedFieldError();
@@ -330,8 +352,16 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
* undefined if triggered programmatically.
* @param quietInput True if editor should be created without focus.
* Defaults to false.
* @param manageEphemeralFocus Whether ephemeral focus should be managed as
* part of the editor's inline editor (when the inline editor is shown).
* Callers must manage ephemeral focus themselves if this is false.
* Defaults to true.
*/
protected override showEditor_(_e?: Event, quietInput = false) {
protected override showEditor_(
_e?: Event,
quietInput = false,
manageEphemeralFocus: boolean = true,
) {
this.workspace_ = (this.sourceBlock_ as BlockSvg).workspace;
if (
!quietInput &&
@@ -340,7 +370,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
) {
this.showPromptEditor();
} else {
this.showInlineEditor(quietInput);
this.showInlineEditor(quietInput, manageEphemeralFocus);
}
}
@@ -367,8 +397,10 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
* Create and show a text input editor that sits directly over the text input.
*
* @param quietInput True if editor should be created without focus.
* @param manageEphemeralFocus Whether ephemeral focus should be managed as
* part of the field's inline editor (widget div).
*/
private showInlineEditor(quietInput: boolean) {
private showInlineEditor(quietInput: boolean, manageEphemeralFocus: boolean) {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
@@ -378,6 +410,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
block.RTL,
this.widgetDispose_.bind(this),
this.workspace_,
manageEphemeralFocus,
);
this.htmlInput_ = this.widgetCreate_() as HTMLInputElement;
this.isBeingEdited_ = true;
@@ -406,7 +439,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
const clickTarget = this.getClickTarget_();
if (!clickTarget) throw new Error('A click target has not been set.');
dom.addClass(clickTarget, 'editing');
dom.addClass(clickTarget, 'blocklyEditing');
const htmlInput = document.createElement('input');
htmlInput.className = 'blocklyHtmlInput';
@@ -416,7 +449,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
'spellcheck',
this.spellcheck_ as AnyDuringMigration,
);
const scale = this.workspace_!.getScale();
const scale = this.workspace_!.getAbsoluteScale();
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt';
div!.style.fontSize = fontSize;
htmlInput.style.fontSize = fontSize;
@@ -501,7 +534,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
const clickTarget = this.getClickTarget_();
if (!clickTarget) throw new Error('A click target has not been set.');
dom.removeClass(clickTarget, 'editing');
dom.removeClass(clickTarget, 'blocklyEditing');
}
/**
@@ -562,10 +595,27 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
WidgetDiv.hideIfOwner(this);
dropDownDiv.hideWithoutAnimation();
} else if (e.key === 'Tab') {
WidgetDiv.hideIfOwner(this);
dropDownDiv.hideWithoutAnimation();
(this.sourceBlock_ as BlockSvg).tab(this, !e.shiftKey);
e.preventDefault();
const cursor = this.workspace_?.getCursor();
const isValidDestination = (node: IFocusableNode | null) =>
(node instanceof FieldInput ||
(node instanceof BlockSvg && node.isSimpleReporter())) &&
node !== this.getSourceBlock();
let target = e.shiftKey
? cursor?.getPreviousNode(this, isValidDestination, false)
: cursor?.getNextNode(this, isValidDestination, false);
target =
target instanceof BlockSvg && target.isSimpleReporter()
? target.getFields().next().value
: target;
if (target instanceof FieldInput) {
WidgetDiv.hideIfOwner(this);
dropDownDiv.hideWithoutAnimation();
target.showEditor();
}
}
}
@@ -673,15 +723,6 @@ export abstract class FieldInput<T extends InputTypes> 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

View File

@@ -74,6 +74,9 @@ export class FieldLabel extends Field<string> {
if (this.class) {
dom.addClass(this.getTextElement(), this.class);
}
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyLabelField');
}
}
/**

View File

@@ -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<number> {
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.
*

View File

@@ -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<string> {
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.
*

View File

@@ -23,13 +23,14 @@ import {
MenuOption,
} from './field_dropdown.js';
import * as fieldRegistry from './field_registry.js';
import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js';
import * as internalConstants from './internal_constants.js';
import type {Menu} from './menu.js';
import type {MenuItem} from './menuitem.js';
import {Msg} from './msg.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
import {VariableModel} from './variable_model.js';
import * as Variables from './variables.js';
import * as Xml from './xml.js';
@@ -48,10 +49,9 @@ export class FieldVariable extends FieldDropdown {
* dropdown.
*/
variableTypes: string[] | null = [];
protected override size_: Size;
/** The variable model associated with this field. */
private variable: VariableModel | null = null;
private variable: IVariableModel<IVariableState> | null = null;
/**
* Serializable fields are saved by the serializer, non-serializable fields
@@ -69,7 +69,8 @@ export class FieldVariable extends FieldDropdown {
* field's value. Takes in a variable ID & returns a validated variable
* ID, or null to abort the change.
* @param variableTypes A list of the types of variables to include in the
* dropdown. Will only be used if config is not provided.
* dropdown. Pass `null` to include all types that exist on the
* workspace. Will only be used if config is not provided.
* @param defaultType The type of variable to create if this field's value
* is not explicitly set. Defaults to ''. Will only be used if config
* is not provided.
@@ -81,7 +82,7 @@ export class FieldVariable extends FieldDropdown {
constructor(
varName: string | null | typeof Field.SKIP_SETUP,
validator?: FieldVariableValidator,
variableTypes?: string[],
variableTypes?: string[] | null,
defaultType?: string,
config?: FieldVariableConfig,
) {
@@ -148,6 +149,11 @@ export class FieldVariable extends FieldDropdown {
this.doValueUpdate_(variable.getId());
}
override initView() {
super.initView();
dom.addClass(this.fieldGroup_!, 'blocklyVariableField');
}
override shouldAddBorderRect_() {
const block = this.getSourceBlock();
if (!block) {
@@ -190,12 +196,12 @@ export class FieldVariable extends FieldDropdown {
);
// This should never happen :)
if (variableType !== null && variableType !== variable.type) {
if (variableType !== null && variableType !== variable.getType()) {
throw Error(
"Serialized variable type with id '" +
variable.getId() +
"' had type " +
variable.type +
variable.getType() +
', and ' +
'does not match variable field that references it: ' +
Xml.domToText(fieldElement) +
@@ -218,9 +224,9 @@ export class FieldVariable extends FieldDropdown {
this.initModel();
fieldElement.id = this.variable!.getId();
fieldElement.textContent = this.variable!.name;
if (this.variable!.type) {
fieldElement.setAttribute('variabletype', this.variable!.type);
fieldElement.textContent = this.variable!.getName();
if (this.variable!.getType()) {
fieldElement.setAttribute('variabletype', this.variable!.getType());
}
return fieldElement;
}
@@ -243,8 +249,8 @@ export class FieldVariable extends FieldDropdown {
this.initModel();
const state = {'id': this.variable!.getId()};
if (doFullSerialization) {
(state as AnyDuringMigration)['name'] = this.variable!.name;
(state as AnyDuringMigration)['type'] = this.variable!.type;
(state as AnyDuringMigration)['name'] = this.variable!.getName();
(state as AnyDuringMigration)['type'] = this.variable!.getType();
}
return state;
}
@@ -301,7 +307,7 @@ export class FieldVariable extends FieldDropdown {
* is selected.
*/
override getText(): string {
return this.variable ? this.variable.name : '';
return this.variable ? this.variable.getName() : '';
}
/**
@@ -312,10 +318,19 @@ export class FieldVariable extends FieldDropdown {
* @returns The selected variable, or null if none was selected.
* @internal
*/
getVariable(): VariableModel | null {
getVariable(): IVariableModel<IVariableState> | null {
return this.variable;
}
/**
* Gets the type of this field's default variable.
*
* @returns The default type for this variable field.
*/
protected getDefaultType(): string {
return this.defaultType;
}
/**
* Gets the validation function for this field, or null if not set.
* Returns null if the variable is not set, because validators should not
@@ -359,7 +374,7 @@ export class FieldVariable extends FieldDropdown {
return null;
}
// Type Checks.
const type = variable.type;
const type = variable.getType();
if (!this.typeIsAllowed(type)) {
console.warn("Variable type doesn't match this field! Type was " + type);
return null;
@@ -407,25 +422,27 @@ export class FieldVariable extends FieldDropdown {
* Return a list of variable types to include in the dropdown.
*
* @returns Array of variable types.
* @throws {Error} if variableTypes is an empty array.
*/
private getVariableTypes(): string[] {
let variableTypes = this.variableTypes;
if (variableTypes === null) {
// If variableTypes is null, return all variable types.
if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) {
return this.sourceBlock_.workspace.getVariableTypes();
}
if (this.variableTypes) return this.variableTypes;
if (!this.sourceBlock_ || this.sourceBlock_.isDeadOrDying()) {
// We should include all types in the block's workspace,
// but the block is dead so just give up.
return [''];
}
variableTypes = variableTypes || [''];
if (variableTypes.length === 0) {
// Throw an error if variableTypes is an empty list.
const name = this.getText();
throw Error(
"'variableTypes' of field variable " + name + ' was an empty list',
);
// If variableTypes is null, return all variable types in the workspace.
let allTypes = this.sourceBlock_.workspace.getVariableMap().getTypes();
if (this.sourceBlock_.isInFlyout) {
// If this block is in a flyout, we also need to check the potential variables
const potentialMap =
this.sourceBlock_.workspace.getPotentialVariableMap();
if (!potentialMap) return allTypes;
allTypes = Array.from(new Set([...allTypes, ...potentialMap.getTypes()]));
}
return variableTypes;
return allTypes;
}
/**
@@ -439,11 +456,15 @@ export class FieldVariable extends FieldDropdown {
* value is not explicitly set. Defaults to ''.
*/
private setTypes(variableTypes: string[] | null = null, defaultType = '') {
// If you expected that the default type would be the same as the only entry
// in the variable types array, tell the Blockly team by commenting on
// #1499.
// Set the allowable variable types. Null means all types on the workspace.
const name = this.getText();
if (Array.isArray(variableTypes)) {
if (variableTypes.length === 0) {
// Throw an error if variableTypes is an empty list.
throw Error(
`'variableTypes' of field variable ${name} was an empty list. If you want to include all variable types, pass 'null' instead.`,
);
}
// Make sure the default type is valid.
let isInArray = false;
for (let i = 0; i < variableTypes.length; i++) {
@@ -461,8 +482,7 @@ export class FieldVariable extends FieldDropdown {
}
} else if (variableTypes !== null) {
throw Error(
"'variableTypes' was not an array in the definition of " +
'a FieldVariable',
`'variableTypes' was not an array or null in the definition of FieldVariable ${name}`,
);
}
// Only update the field once all checks pass.
@@ -493,16 +513,14 @@ export class FieldVariable extends FieldDropdown {
const id = menuItem.getValue();
// Handle special cases.
if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) {
if (id === internalConstants.RENAME_VARIABLE_ID) {
if (id === internalConstants.RENAME_VARIABLE_ID && this.variable) {
// Rename variable.
Variables.renameVariable(
this.sourceBlock_.workspace,
this.variable as VariableModel,
);
Variables.renameVariable(this.sourceBlock_.workspace, this.variable);
return;
} else if (id === internalConstants.DELETE_VARIABLE_ID) {
} else if (id === internalConstants.DELETE_VARIABLE_ID && this.variable) {
// Delete variable.
this.sourceBlock_.workspace.deleteVariableById(this.variable!.getId());
const workspace = this.variable.getWorkspace();
Variables.deleteVariable(workspace, this.variable, this.sourceBlock_);
return;
}
}
@@ -554,24 +572,37 @@ export class FieldVariable extends FieldDropdown {
);
}
const name = this.getText();
let variableModelList: VariableModel[] = [];
if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) {
let variableModelList: IVariableModel<IVariableState>[] = [];
const sourceBlock = this.getSourceBlock();
if (sourceBlock && !sourceBlock.isDeadOrDying()) {
const workspace = sourceBlock.workspace;
const variableTypes = this.getVariableTypes();
// Get a copy of the list, so that adding rename and new variable options
// doesn't modify the workspace's list.
for (let i = 0; i < variableTypes.length; i++) {
const variableType = variableTypes[i];
const variables =
this.sourceBlock_.workspace.getVariablesOfType(variableType);
const variables = workspace
.getVariableMap()
.getVariablesOfType(variableType);
variableModelList = variableModelList.concat(variables);
if (workspace.isFlyout) {
variableModelList = variableModelList.concat(
workspace
.getPotentialVariableMap()
?.getVariablesOfType(variableType) ?? [],
);
}
}
}
variableModelList.sort(VariableModel.compareByName);
variableModelList.sort(Variables.compareByName);
const options: [string, string][] = [];
for (let i = 0; i < variableModelList.length; i++) {
// Set the UUID as the internal representation of the variable.
options[i] = [variableModelList[i].name, variableModelList[i].getId()];
options[i] = [
variableModelList[i].getName(),
variableModelList[i].getId(),
];
}
options.push([
Msg['RENAME_VARIABLE'],

View File

@@ -11,53 +11,43 @@
*/
// Former goog.module ID: Blockly.Flyout
import type {Block} from './block.js';
import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import {ComponentManager} from './component_manager.js';
import {MANUALLY_DISABLED} from './constants.js';
import {DeleteArea} from './delete_area.js';
import type {Abstract as AbstractEvent} from './events/events_abstract.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {FlyoutButton} from './flyout_button.js';
import {FlyoutItem} from './flyout_item.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutNavigator} from './flyout_navigator.js';
import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js';
import {getFocusManager} from './focus_manager.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import type {Options} from './options.js';
import * as registry from './registry.js';
import * as renderManagement from './render_management.js';
import {ScrollbarPair} from './scrollbar_pair.js';
import {SEPARATOR_TYPE} from './separator_flyout_inflater.js';
import * as blocks from './serialization/blocks.js';
import * as Tooltip from './tooltip.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
import {Svg} from './utils/svg.js';
import * as toolbox from './utils/toolbox.js';
import * as utilsXml from './utils/xml.js';
import * as Variables from './variables.js';
import {WorkspaceSvg} from './workspace_svg.js';
import * as Xml from './xml.js';
enum FlyoutItemType {
BLOCK = 'block',
BUTTON = 'button',
}
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the workspace is at block capacity.
*/
const WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON =
'WORKSPACE_AT_BLOCK_CAPACITY';
/**
* Class for a flyout.
*/
export abstract class Flyout
extends DeleteArea
implements IAutoHideable, IFlyout
implements IAutoHideable, IFlyout, IFocusableNode
{
/**
* Position the flyout.
@@ -85,12 +75,11 @@ export abstract class Flyout
protected abstract setMetrics_(xyRatio: {x?: number; y?: number}): void;
/**
* Lay out the blocks in the flyout.
* Lay out the elements in the flyout.
*
* @param contents The blocks and buttons to lay out.
* @param gaps The visible gaps between blocks.
* @param contents The flyout elements to lay out.
*/
protected abstract layout_(contents: FlyoutItem[], gaps: number[]): void;
protected abstract layout_(contents: FlyoutItem[]): void;
/**
* Scroll the flyout.
@@ -100,8 +89,8 @@ export abstract class Flyout
protected abstract wheel_(e: WheelEvent): void;
/**
* Compute height of flyout. Position mat under each block.
* For RTL: Lay out the blocks right-aligned.
* Compute bounds of flyout.
* For RTL: Lay out the elements right-aligned.
*/
protected abstract reflowInternal_(): void;
@@ -124,11 +113,6 @@ export abstract class Flyout
*/
abstract scrollToStart(): void;
/**
* The type of a flyout content item.
*/
static FlyoutItemType = FlyoutItemType;
protected workspace_: WorkspaceSvg;
RTL: boolean;
/**
@@ -148,43 +132,15 @@ export abstract class Flyout
/**
* Function that will be registered as a change listener on the workspace
* to reflow when blocks in the flyout workspace change.
* to reflow when elements in the flyout workspace change.
*/
private reflowWrapper: ((e: AbstractEvent) => void) | null = null;
/**
* Function that disables blocks in the flyout based on max block counts
* allowed in the target workspace. Registered as a change listener on the
* target workspace.
*/
private filterWrapper: ((e: AbstractEvent) => void) | null = null;
/**
* List of background mats that lurk behind each block to catch clicks
* landing in the blocks' lakes and bays.
*/
private mats: SVGElement[] = [];
/**
* List of visible buttons.
*/
protected buttons_: FlyoutButton[] = [];
/**
* List of visible buttons and blocks.
* List of flyout elements.
*/
protected contents: FlyoutItem[] = [];
/**
* List of event listeners.
*/
private listeners: browserEvents.Data[] = [];
/**
* List of blocks that should always be disabled.
*/
private permanentlyDisabled: Block[] = [];
protected readonly tabWidth_: number;
/**
@@ -194,11 +150,6 @@ export abstract class Flyout
*/
targetWorkspace!: WorkspaceSvg;
/**
* A list of blocks that can be reused.
*/
private recycledBlocks: BlockSvg[] = [];
/**
* Does the flyout automatically close when a block is created?
*/
@@ -213,7 +164,6 @@ export abstract class Flyout
* Whether the workspace containing this flyout is visible.
*/
private containerVisible = true;
protected rectMap_: WeakMap<BlockSvg, SVGElement>;
/**
* Corner radius of the flyout background.
@@ -271,6 +221,13 @@ export abstract class Flyout
* The root SVG group for the button or label.
*/
protected svgGroup_: SVGGElement | null = null;
/**
* Map from flyout content type to the corresponding inflater class
* responsible for creating concrete instances of the content type.
*/
protected inflaters = new Map<string, IFlyoutInflater>();
/**
* @param workspaceOptions Dictionary of options for the
* workspace.
@@ -287,6 +244,7 @@ export abstract class Flyout
this.workspace_.internalIsFlyout = true;
// Keep the workspace visibility consistent with the flyout's visibility.
this.workspace_.setVisible(this.visible);
this.workspace_.setNavigator(new FlyoutNavigator(this));
/**
* The unique id for this component that is used to register with the
@@ -310,15 +268,7 @@ export abstract class Flyout
this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH;
/**
* A map from blocks to the rects which are beneath them to act as input
* targets.
*
* @internal
*/
this.rectMap_ = new WeakMap();
/**
* Margin around the edges of the blocks in the flyout.
* Margin around the edges of the elements in the flyout.
*/
this.MARGIN = this.CORNER_RADIUS;
@@ -358,6 +308,7 @@ export abstract class Flyout
// hide/show code will set up proper visibility and size later.
this.svgGroup_ = dom.createSvgElement(tagName, {
'class': 'blocklyFlyout',
'tabindex': '0',
});
this.svgGroup_.style.display = 'none';
this.svgBackground_ = dom.createSvgElement(
@@ -372,6 +323,9 @@ export abstract class Flyout
this.workspace_
.getThemeManager()
.subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity');
getFocusManager().registerTree(this);
return this.svgGroup_;
}
@@ -403,8 +357,6 @@ export abstract class Flyout
this.wheel_,
),
);
this.filterWrapper = this.filterForCapacity.bind(this);
this.targetWorkspace.addChangeListener(this.filterWrapper);
// Dragging the flyout up and down.
this.boundEvents.push(
@@ -448,9 +400,6 @@ export abstract class Flyout
browserEvents.unbind(event);
}
this.boundEvents.length = 0;
if (this.filterWrapper) {
this.targetWorkspace.removeChangeListener(this.filterWrapper);
}
if (this.workspace_) {
this.workspace_.getThemeManager().unsubscribe(this.svgBackground_!);
this.workspace_.dispose();
@@ -458,6 +407,7 @@ export abstract class Flyout
if (this.svgGroup_) {
dom.removeNode(this.svgGroup_);
}
getFocusManager().unregisterTree(this);
}
/**
@@ -570,16 +520,16 @@ export abstract class Flyout
}
/**
* Get the list of buttons and blocks of the current flyout.
* Get the list of elements of the current flyout.
*
* @returns The array of flyout buttons and blocks.
* @returns The array of flyout elements.
*/
getContents(): FlyoutItem[] {
return this.contents;
}
/**
* Store the list of buttons and blocks on the flyout.
* Store the list of elements on the flyout.
*
* @param contents - The array of items for the flyout.
*/
@@ -654,16 +604,11 @@ export abstract class Flyout
return;
}
this.setVisible(false);
// Delete all the event listeners.
for (const listen of this.listeners) {
browserEvents.unbind(listen);
}
this.listeners.length = 0;
if (this.reflowWrapper) {
this.workspace_.removeChangeListener(this.reflowWrapper);
this.reflowWrapper = null;
}
// Do NOT delete the blocks here. Wait until Flyout.show.
// Do NOT delete the flyout contents here. Wait until Flyout.show.
// https://neil.fraser.name/news/2014/08/09/
}
@@ -691,26 +636,30 @@ export abstract class Flyout
renderManagement.triggerQueuedRenders(this.workspace_);
this.setContents(flyoutInfo.contents);
this.setContents(flyoutInfo);
this.layout_(flyoutInfo.contents, flyoutInfo.gaps);
this.layout_(flyoutInfo);
if (this.horizontalLayout) {
this.height_ = 0;
} else {
this.width_ = 0;
}
this.workspace_.setResizesEnabled(true);
this.reflow();
this.workspace_.setResizesEnabled(true);
this.filterForCapacity();
// Correctly position the flyout's scrollbar when it opens.
this.position();
this.reflowWrapper = this.reflow.bind(this);
// Listen for block change events, and reflow the flyout in response. This
// accommodates e.g. resizing a non-autoclosing flyout in response to the
// user typing long strings into fields on the blocks in the flyout.
this.reflowWrapper = (event) => {
if (
event.type === EventType.BLOCK_CHANGE ||
event.type === EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE
) {
this.reflow();
}
};
this.workspace_.addChangeListener(this.reflowWrapper);
this.emptyRecycledBlocks();
}
/**
@@ -719,15 +668,12 @@ export abstract class Flyout
*
* @param parsedContent The array
* of objects to show in the flyout.
* @returns The list of contents and gaps needed to lay out the flyout.
* @returns The list of contents needed to lay out the flyout.
*/
private createFlyoutInfo(parsedContent: toolbox.FlyoutItemInfoArray): {
contents: FlyoutItem[];
gaps: number[];
} {
private createFlyoutInfo(
parsedContent: toolbox.FlyoutItemInfoArray,
): FlyoutItem[] {
const contents: FlyoutItem[] = [];
const gaps: number[] = [];
this.permanentlyDisabled.length = 0;
const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y;
for (const info of parsedContent) {
if ('custom' in info) {
@@ -736,44 +682,59 @@ export abstract class Flyout
const flyoutDef = this.getDynamicCategoryContents(categoryName);
const parsedDynamicContent =
toolbox.convertFlyoutDefToJsonArray(flyoutDef);
const {contents: dynamicContents, gaps: dynamicGaps} =
this.createFlyoutInfo(parsedDynamicContent);
contents.push(...dynamicContents);
gaps.push(...dynamicGaps);
contents.push(...this.createFlyoutInfo(parsedDynamicContent));
}
switch (info['kind'].toUpperCase()) {
case 'BLOCK': {
const blockInfo = info as toolbox.BlockInfo;
const block = this.createFlyoutBlock(blockInfo);
contents.push({type: FlyoutItemType.BLOCK, block: block});
this.addBlockGap(blockInfo, gaps, defaultGap);
break;
}
case 'SEP': {
const sepInfo = info as toolbox.SeparatorInfo;
this.addSeparatorGap(sepInfo, gaps, defaultGap);
break;
}
case 'LABEL': {
const labelInfo = info as toolbox.LabelInfo;
// A label is a button with different styling.
const label = this.createButton(labelInfo, /** isLabel */ true);
contents.push({type: FlyoutItemType.BUTTON, button: label});
gaps.push(defaultGap);
break;
}
case 'BUTTON': {
const buttonInfo = info as toolbox.ButtonInfo;
const button = this.createButton(buttonInfo, /** isLabel */ false);
contents.push({type: FlyoutItemType.BUTTON, button: button});
gaps.push(defaultGap);
break;
const type = info['kind'].toLowerCase();
const inflater = this.getInflaterForType(type);
if (inflater) {
contents.push(inflater.load(info, this));
const gap = inflater.gapForItem(info, defaultGap);
if (gap) {
contents.push(
new FlyoutItem(
new FlyoutSeparator(
gap,
this.horizontalLayout ? SeparatorAxis.X : SeparatorAxis.Y,
),
SEPARATOR_TYPE,
),
);
}
}
}
return {contents: contents, gaps: gaps};
return this.normalizeSeparators(contents);
}
/**
* Updates and returns the provided list of flyout contents to flatten
* separators as needed.
*
* When multiple separators occur one after another, the value of the last one
* takes precedence and the earlier separators in the group are removed.
*
* @param contents The list of flyout contents to flatten separators in.
* @returns An updated list of flyout contents with only one separator between
* each non-separator item.
*/
protected normalizeSeparators(contents: FlyoutItem[]): FlyoutItem[] {
for (let i = contents.length - 1; i > 0; i--) {
const elementType = contents[i].getType().toLowerCase();
const previousElementType = contents[i - 1].getType().toLowerCase();
if (
elementType === SEPARATOR_TYPE &&
previousElementType === SEPARATOR_TYPE
) {
// Remove previousElement from the array, shifting the current element
// forward as a result. This preserves the behavior where explicit
// separator elements override the value of prior implicit (or explicit)
// separator elements.
contents.splice(i - 1, 1);
}
}
return contents;
}
/**
@@ -800,287 +761,18 @@ export abstract class Flyout
}
/**
* Creates a flyout button or a flyout label.
*
* @param btnInfo The object holding information about a button or a label.
* @param isLabel True if the button is a label, false otherwise.
* @returns The object used to display the button in the
* flyout.
*/
private createButton(
btnInfo: toolbox.ButtonOrLabelInfo,
isLabel: boolean,
): FlyoutButton {
const curButton = new FlyoutButton(
this.workspace_,
this.targetWorkspace as WorkspaceSvg,
btnInfo,
isLabel,
);
return curButton;
}
/**
* Create a block from the xml and permanently disable any blocks that were
* defined as disabled.
*
* @param blockInfo The info of the block.
* @returns The block created from the blockInfo.
*/
private createFlyoutBlock(blockInfo: toolbox.BlockInfo): BlockSvg {
let block;
if (blockInfo['blockxml']) {
const xml = (
typeof blockInfo['blockxml'] === 'string'
? utilsXml.textToDom(blockInfo['blockxml'])
: blockInfo['blockxml']
) as Element;
block = this.getRecycledBlock(xml.getAttribute('type')!);
if (!block) {
block = Xml.domToBlockInternal(xml, this.workspace_);
}
} else {
block = this.getRecycledBlock(blockInfo['type']!);
if (!block) {
if (blockInfo['enabled'] === undefined) {
blockInfo['enabled'] =
blockInfo['disabled'] !== 'true' && blockInfo['disabled'] !== true;
}
if (
blockInfo['disabledReasons'] === undefined &&
blockInfo['enabled'] === false
) {
blockInfo['disabledReasons'] = [MANUALLY_DISABLED];
}
block = blocks.appendInternal(
blockInfo as blocks.State,
this.workspace_,
);
}
}
if (!block.isEnabled()) {
// Record blocks that were initially disabled.
// Do not enable these blocks as a result of capacity filtering.
this.permanentlyDisabled.push(block);
}
return block as BlockSvg;
}
/**
* Returns a block from the array of recycled blocks with the given type, or
* undefined if one cannot be found.
*
* @param blockType The type of the block to try to recycle.
* @returns The recycled block, or undefined if
* one could not be recycled.
*/
private getRecycledBlock(blockType: string): BlockSvg | undefined {
let index = -1;
for (let i = 0; i < this.recycledBlocks.length; i++) {
if (this.recycledBlocks[i].type === blockType) {
index = i;
break;
}
}
return index === -1 ? undefined : this.recycledBlocks.splice(index, 1)[0];
}
/**
* Adds a gap in the flyout based on block info.
*
* @param blockInfo Information about a block.
* @param gaps The list of gaps between items in the flyout.
* @param defaultGap The default gap between one element and the
* next.
*/
private addBlockGap(
blockInfo: toolbox.BlockInfo,
gaps: number[],
defaultGap: number,
) {
let gap;
if (blockInfo['gap']) {
gap = parseInt(String(blockInfo['gap']));
} else if (blockInfo['blockxml']) {
const xml = (
typeof blockInfo['blockxml'] === 'string'
? utilsXml.textToDom(blockInfo['blockxml'])
: blockInfo['blockxml']
) as Element;
gap = parseInt(xml.getAttribute('gap')!);
}
gaps.push(!gap || isNaN(gap) ? defaultGap : gap);
}
/**
* Add the necessary gap in the flyout for a separator.
*
* @param sepInfo The object holding
* information about a separator.
* @param gaps The list gaps between items in the flyout.
* @param defaultGap The default gap between the button and next
* element.
*/
private addSeparatorGap(
sepInfo: toolbox.SeparatorInfo,
gaps: number[],
defaultGap: number,
) {
// Change the gap between two toolbox elements.
// <sep gap="36"></sep>
// The default gap is 24, can be set larger or smaller.
// This overwrites the gap attribute on the previous element.
const newGap = parseInt(String(sepInfo['gap']));
// Ignore gaps before the first block.
if (!isNaN(newGap) && gaps.length > 0) {
gaps[gaps.length - 1] = newGap;
} else {
gaps.push(defaultGap);
}
}
/**
* Delete blocks, mats and buttons from a previous showing of the flyout.
* Delete elements from a previous showing of the flyout.
*/
private clearOldBlocks() {
// Delete any blocks from a previous showing.
const oldBlocks = this.workspace_.getTopBlocks(false);
for (let i = 0, block; (block = oldBlocks[i]); i++) {
if (this.blockIsRecyclable_(block)) {
this.recycleBlock(block);
} else {
block.dispose(false, false);
}
}
// Delete any mats from a previous showing.
for (let j = 0; j < this.mats.length; j++) {
const rect = this.mats[j];
if (rect) {
Tooltip.unbindMouseEvents(rect);
dom.removeNode(rect);
}
}
this.mats.length = 0;
// Delete any buttons from a previous showing.
for (let i = 0, button; (button = this.buttons_[i]); i++) {
button.dispose();
}
this.buttons_.length = 0;
this.getContents().forEach((item) => {
const inflater = this.getInflaterForType(item.getType());
inflater?.disposeItem(item);
});
// Clear potential variables from the previous showing.
this.workspace_.getPotentialVariableMap()?.clear();
}
/**
* Empties all of the recycled blocks, properly disposing of them.
*/
private emptyRecycledBlocks() {
for (let i = 0; i < this.recycledBlocks.length; i++) {
this.recycledBlocks[i].dispose();
}
this.recycledBlocks = [];
}
/**
* Returns whether the given block can be recycled or not.
*
* @param _block The block to check for recyclability.
* @returns True if the block can be recycled. False otherwise.
*/
protected blockIsRecyclable_(_block: BlockSvg): boolean {
// By default, recycling is disabled.
return false;
}
/**
* Puts a previously created block into the recycle bin and moves it to the
* top of the workspace. Used during large workspace swaps to limit the number
* of new DOM elements we need to create.
*
* @param block The block to recycle.
*/
private recycleBlock(block: BlockSvg) {
const xy = block.getRelativeToSurfaceXY();
block.moveBy(-xy.x, -xy.y);
this.recycledBlocks.push(block);
}
/**
* Add listeners to a block that has been added to the flyout.
*
* @param root The root node of the SVG group the block is in.
* @param block The block to add listeners for.
* @param rect The invisible rectangle under the block that acts
* as a mat for that block.
*/
protected addBlockListeners_(
root: SVGElement,
block: BlockSvg,
rect: SVGElement,
) {
this.listeners.push(
browserEvents.conditionalBind(
root,
'pointerdown',
null,
this.blockMouseDown(block),
),
);
this.listeners.push(
browserEvents.conditionalBind(
rect,
'pointerdown',
null,
this.blockMouseDown(block),
),
);
this.listeners.push(
browserEvents.bind(root, 'pointerenter', block, () => {
if (!this.targetWorkspace.isDragging()) {
block.addSelect();
}
}),
);
this.listeners.push(
browserEvents.bind(root, 'pointerleave', block, () => {
if (!this.targetWorkspace.isDragging()) {
block.removeSelect();
}
}),
);
this.listeners.push(
browserEvents.bind(rect, 'pointerenter', block, () => {
if (!this.targetWorkspace.isDragging()) {
block.addSelect();
}
}),
);
this.listeners.push(
browserEvents.bind(rect, 'pointerleave', block, () => {
if (!this.targetWorkspace.isDragging()) {
block.removeSelect();
}
}),
);
}
/**
* Handle a pointerdown on an SVG block in a non-closing flyout.
*
* @param block The flyout block to copy.
* @returns Function to call when block is clicked.
*/
private blockMouseDown(block: BlockSvg) {
return (e: PointerEvent) => {
const gesture = this.targetWorkspace.getGesture(e);
if (gesture) {
gesture.setStartBlock(block);
gesture.handleFlyoutStart(e, this);
}
};
}
/**
* Pointer down on the flyout background. Start a vertical scroll drag.
*
@@ -1103,7 +795,7 @@ export abstract class Flyout
* @internal
*/
isBlockCreatable(block: BlockSvg): boolean {
return block.isEnabled();
return block.isEnabled() && !this.getTargetWorkspace().isReadOnly();
}
/**
@@ -1149,123 +841,12 @@ export abstract class Flyout
}
if (this.autoClose) {
this.hide();
} else {
this.filterForCapacity();
}
return newBlock;
}
/**
* Initialize the given button: move it to the correct location,
* add listeners, etc.
*
* @param button The button to initialize and place.
* @param x The x position of the cursor during this layout pass.
* @param y The y position of the cursor during this layout pass.
*/
protected initFlyoutButton_(button: FlyoutButton, x: number, y: number) {
const buttonSvg = button.createDom();
button.moveTo(x, y);
button.show();
// Clicking on a flyout button or label is a lot like clicking on the
// flyout background.
this.listeners.push(
browserEvents.conditionalBind(
buttonSvg,
'pointerdown',
this,
this.onMouseDown,
),
);
this.buttons_.push(button);
}
/**
* Create and place a rectangle corresponding to the given block.
*
* @param block The block to associate the rect to.
* @param x The x position of the cursor during this layout pass.
* @param y The y position of the cursor during this layout pass.
* @param blockHW The height and width of
* the block.
* @param index The index into the mats list where this rect should
* be placed.
* @returns Newly created SVG element for the rectangle behind
* the block.
*/
protected createRect_(
block: BlockSvg,
x: number,
y: number,
blockHW: {height: number; width: number},
index: number,
): SVGElement {
// Create an invisible rectangle under the block to act as a button. Just
// using the block as a button is poor, since blocks have holes in them.
const rect = dom.createSvgElement(Svg.RECT, {
'fill-opacity': 0,
'x': x,
'y': y,
'height': blockHW.height,
'width': blockHW.width,
});
(rect as AnyDuringMigration).tooltip = block;
Tooltip.bindMouseEvents(rect);
// Add the rectangles under the blocks, so that the blocks' tooltips work.
this.workspace_.getCanvas().insertBefore(rect, block.getSvgRoot());
this.rectMap_.set(block, rect);
this.mats[index] = rect;
return rect;
}
/**
* Move a rectangle to sit exactly behind a block, taking into account tabs,
* hats, and any other protrusions we invent.
*
* @param rect The rectangle to move directly behind the block.
* @param block The block the rectangle should be behind.
*/
protected moveRectToBlock_(rect: SVGElement, block: BlockSvg) {
const blockHW = block.getHeightWidth();
rect.setAttribute('width', String(blockHW.width));
rect.setAttribute('height', String(blockHW.height));
const blockXY = block.getRelativeToSurfaceXY();
rect.setAttribute('y', String(blockXY.y));
rect.setAttribute(
'x',
String(this.RTL ? blockXY.x - blockHW.width : blockXY.x),
);
}
/**
* Filter the blocks on the flyout to disable the ones that are above the
* capacity limit. For instance, if the user may only place two more blocks
* on the workspace, an "a + b" block that has two shadow blocks would be
* disabled.
*/
private filterForCapacity() {
const blocks = this.workspace_.getTopBlocks(false);
for (let i = 0, block; (block = blocks[i]); i++) {
if (!this.permanentlyDisabled.includes(block)) {
const enable = this.targetWorkspace.isCapacityAvailable(
common.getBlockTypeCounts(block),
);
while (block) {
block.setDisabledReason(
!enable,
WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON,
);
block = block.getNextBlock();
}
}
}
}
/**
* Reflow blocks and their mats.
* Reflow flyout contents.
*/
reflow() {
if (this.reflowWrapper) {
@@ -1364,13 +945,93 @@ export abstract class Flyout
// No 'reason' provided since events are disabled.
block.moveTo(new Coordinate(finalOffset.x, finalOffset.y));
}
}
/**
* A flyout content item.
*/
export interface FlyoutItem {
type: FlyoutItemType;
button?: FlyoutButton | undefined;
block?: BlockSvg | undefined;
/**
* Returns the inflater responsible for constructing items of the given type.
*
* @param type The type of flyout content item to provide an inflater for.
* @returns An inflater object for the given type, or null if no inflater
* is registered for that type.
*/
protected getInflaterForType(type: string): IFlyoutInflater | null {
if (this.inflaters.has(type)) {
return this.inflaters.get(type) ?? null;
}
const InflaterClass = registry.getClass(
registry.Type.FLYOUT_INFLATER,
type,
);
if (InflaterClass) {
const inflater = new InflaterClass();
this.inflaters.set(type, inflater);
return inflater;
}
return null;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.');
return this.svgGroup_;
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
/** See IFocusableTree.getRootFocusableNode. */
getRootFocusableNode(): IFocusableNode {
return this;
}
/** See IFocusableTree.getRestoredFocusableNode. */
getRestoredFocusableNode(
_previousNode: IFocusableNode | null,
): IFocusableNode | null {
return null;
}
/** See IFocusableTree.getNestedTrees. */
getNestedTrees(): Array<IFocusableTree> {
return [this.workspace_];
}
/** See IFocusableTree.lookUpFocusableNode. */
lookUpFocusableNode(_id: string): IFocusableNode | null {
// No focusable node needs to be returned since the flyout's subtree is a
// workspace that will manage its own focusable state.
return null;
}
/** See IFocusableTree.onTreeFocus. */
onTreeFocus(
_node: IFocusableNode,
_previousTree: IFocusableTree | null,
): void {}
/** See IFocusableTree.onTreeBlur. */
onTreeBlur(nextTree: IFocusableTree | null): void {
const toolbox = this.targetWorkspace.getToolbox();
// If focus is moving to either the toolbox or the flyout's workspace, do
// not close the flyout. For anything else, do close it since the flyout is
// no longer focused.
if (toolbox && nextTree === toolbox) return;
if (nextTree === this.workspace_) return;
if (toolbox) toolbox.clearSelection();
this.autoHide(false);
}
}

View File

@@ -11,12 +11,17 @@
*/
// Former goog.module ID: Blockly.FlyoutButton
import type {IASTNodeLocationSvg} from './blockly.js';
import * as browserEvents from './browser_events.js';
import * as Css from './css.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import type {IRenderedElement} from './interfaces/i_rendered_element.js';
import {idGenerator} from './utils.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Rect} from './utils/rect.js';
import * as style from './utils/style.js';
import {Svg} from './utils/svg.js';
import type * as toolbox from './utils/toolbox.js';
@@ -25,7 +30,9 @@ import type {WorkspaceSvg} from './workspace_svg.js';
/**
* Class for a button or label in the flyout.
*/
export class FlyoutButton implements IASTNodeLocationSvg {
export class FlyoutButton
implements IBoundedElement, IRenderedElement, IFocusableNode
{
/** The horizontal margin around the text in the button. */
static TEXT_MARGIN_X = 5;
@@ -41,7 +48,8 @@ export class FlyoutButton implements IASTNodeLocationSvg {
private readonly cssClass: string | null;
/** Mouse up event data. */
private onMouseUpWrapper: browserEvents.Data | null = null;
private onMouseDownWrapper: browserEvents.Data;
private onMouseUpWrapper: browserEvents.Data;
info: toolbox.ButtonOrLabelInfo;
/** The width of the button's rect. */
@@ -51,7 +59,7 @@ export class FlyoutButton implements IASTNodeLocationSvg {
height = 0;
/** The root SVG group for the button or label. */
private svgGroup: SVGGElement | null = null;
private svgGroup: SVGGElement;
/** The SVG element with the text of the label or button. */
private svgText: SVGTextElement | null = null;
@@ -62,6 +70,9 @@ export class FlyoutButton implements IASTNodeLocationSvg {
*/
cursorSvg: SVGElement | null = null;
/** The unique ID for this FlyoutButton. */
private id: string;
/**
* @param workspace The workspace in which to place this button.
* @param targetWorkspace The flyout's target workspace.
@@ -92,14 +103,6 @@ export class FlyoutButton implements IASTNodeLocationSvg {
/** The JSON specifying the label / button. */
this.info = json;
}
/**
* Create the button elements.
*
* @returns The button's SVG group.
*/
createDom(): SVGElement {
let cssClass = this.isFlyoutLabel
? 'blocklyFlyoutLabel'
: 'blocklyFlyoutButton';
@@ -107,9 +110,10 @@ export class FlyoutButton implements IASTNodeLocationSvg {
cssClass += ' ' + this.cssClass;
}
this.id = idGenerator.getNextUniqueId();
this.svgGroup = dom.createSvgElement(
Svg.G,
{'class': cssClass},
{'id': this.id, 'class': cssClass, 'tabindex': '-1'},
this.workspace.getCanvas(),
);
@@ -179,7 +183,7 @@ export class FlyoutButton implements IASTNodeLocationSvg {
fontWeight,
fontFamily,
);
this.height = fontMetrics.height;
this.height = this.height || fontMetrics.height;
if (!this.isFlyoutLabel) {
this.width += 2 * FlyoutButton.TEXT_MARGIN_X;
@@ -198,15 +202,24 @@ export class FlyoutButton implements IASTNodeLocationSvg {
this.updateTransform();
// AnyDuringMigration because: Argument of type 'SVGGElement | null' is not
// assignable to parameter of type 'EventTarget'.
this.onMouseDownWrapper = browserEvents.conditionalBind(
this.svgGroup,
'pointerdown',
this,
this.onMouseDown,
);
this.onMouseUpWrapper = browserEvents.conditionalBind(
this.svgGroup as AnyDuringMigration,
this.svgGroup,
'pointerup',
this,
this.onMouseUp,
);
return this.svgGroup!;
}
createDom(): SVGElement {
// No-op, now handled in constructor. Will be removed in followup refactor
// PR that updates the flyout classes to use inflaters.
return this.svgGroup;
}
/** Correctly position the flyout button and make it visible. */
@@ -235,6 +248,17 @@ export class FlyoutButton implements IASTNodeLocationSvg {
this.updateTransform();
}
/**
* Move the element by a relative offset.
*
* @param dx Horizontal offset in workspace units.
* @param dy Vertical offset in workspace units.
* @param _reason Why is this move happening? 'user', 'bump', 'snap'...
*/
moveBy(dx: number, dy: number, _reason?: string[]) {
this.moveTo(this.position.x + dx, this.position.y + dy);
}
/** @returns Whether or not the button is a label. */
isLabel(): boolean {
return this.isFlyoutLabel;
@@ -250,6 +274,21 @@ export class FlyoutButton implements IASTNodeLocationSvg {
return this.position;
}
/**
* Returns the coordinates of a bounded element describing the dimensions of
* the element. Coordinate system: workspace coordinates.
*
* @returns Object with coordinates of the bounded element.
*/
getBoundingRectangle() {
return new Rect(
this.position.y,
this.position.y + this.height,
this.position.x,
this.position.x + this.width,
);
}
/** @returns Text of the button. */
getButtonText(): string {
return this.text;
@@ -275,9 +314,8 @@ export class FlyoutButton implements IASTNodeLocationSvg {
/** Dispose of this button. */
dispose() {
if (this.onMouseUpWrapper) {
browserEvents.unbind(this.onMouseUpWrapper);
}
browserEvents.unbind(this.onMouseDownWrapper);
browserEvents.unbind(this.onMouseUpWrapper);
if (this.svgGroup) {
dom.removeNode(this.svgGroup);
}
@@ -303,15 +341,6 @@ export class FlyoutButton implements IASTNodeLocationSvg {
}
}
/**
* Required by IASTNodeLocationSvg, but not used. A marker cannot be set on a
* button. If the 'mark' shortcut is used on a button, its associated callback
* function is triggered.
*/
setMarkerSvg() {
throw new Error('Attempted to set a marker on a button.');
}
/**
* Do something when the button is clicked.
*
@@ -342,6 +371,42 @@ export class FlyoutButton implements IASTNodeLocationSvg {
}
}
}
private onMouseDown(e: PointerEvent) {
const gesture = this.targetWorkspace.getGesture(e);
const flyout = this.targetWorkspace.getFlyout();
if (gesture && flyout) {
gesture.handleFlyoutStart(e, flyout);
}
}
/**
* @returns The root SVG element of this rendered element.
*/
getSvgRoot() {
return this.svgGroup;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
return this.svgGroup;
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this.workspace;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
}
/** CSS for buttons and labels. See css.js for use. */

View File

@@ -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.

33
core/flyout_item.ts Normal file
View File

@@ -0,0 +1,33 @@
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
/**
* Representation of an item displayed in a flyout.
*/
export class FlyoutItem {
/**
* Creates a new FlyoutItem.
*
* @param element The element that will be displayed in the flyout.
* @param type The type of element. Should correspond to the type of the
* flyout inflater that created this object.
*/
constructor(
private element: IBoundedElement & IFocusableNode,
private type: string,
) {}
/**
* Returns the element displayed in the flyout.
*/
getElement() {
return this.element;
}
/**
* Returns the type of flyout element this item represents.
*/
getType() {
return this.type;
}
}

24
core/flyout_navigator.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFlyout} from './interfaces/i_flyout.js';
import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js';
import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js';
import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js';
import {Navigator} from './navigator.js';
export class FlyoutNavigator extends Navigator {
constructor(flyout: IFlyout) {
super();
this.rules.push(
new FlyoutButtonNavigationPolicy(),
new FlyoutSeparatorNavigationPolicy(),
);
this.rules = this.rules.map(
(rule) => new FlyoutNavigationPolicy(rule, flyout),
);
}
}

94
core/flyout_separator.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {Rect} from './utils/rect.js';
/**
* Representation of a gap between elements in a flyout.
*/
export class FlyoutSeparator implements IBoundedElement, IFocusableNode {
private x = 0;
private y = 0;
/**
* Creates a new separator.
*
* @param gap The amount of space this separator should occupy.
* @param axis The axis along which this separator occupies space.
*/
constructor(
private gap: number,
private axis: SeparatorAxis,
) {}
/**
* Returns the bounding box of this separator.
*
* @returns The bounding box of this separator.
*/
getBoundingRectangle(): Rect {
switch (this.axis) {
case SeparatorAxis.X:
return new Rect(this.y, this.y, this.x, this.x + this.gap);
case SeparatorAxis.Y:
return new Rect(this.y, this.y + this.gap, this.x, this.x);
}
}
/**
* Repositions this separator.
*
* @param dx The distance to move this separator on the X axis.
* @param dy The distance to move this separator on the Y axis.
* @param _reason The reason this move was initiated.
*/
moveBy(dx: number, dy: number, _reason?: string[]) {
this.x += dx;
this.y += dy;
}
/**
* Returns false to prevent this separator from being navigated to by the
* keyboard.
*
* @returns False.
*/
isNavigable() {
return false;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
throw new Error('Cannot be focused');
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
throw new Error('Cannot be focused');
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return false;
}
}
/**
* Representation of an axis along which a separator occupies space.
*/
export const enum SeparatorAxis {
X = 'x',
Y = 'y',
}

View File

@@ -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);
}
}

520
core/focus_manager.ts Normal file
View File

@@ -0,0 +1,520 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import * as dom from './utils/dom.js';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
/**
* Type declaration for returning focus to FocusManager upon completing an
* ephemeral UI flow (such as a dialog).
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type ReturnEphemeralFocus = () => void;
/**
* A per-page singleton that manages Blockly focus across one or more
* IFocusableTrees, and bidirectionally synchronizes this focus with the DOM.
*
* Callers that wish to explicitly change input focus for select Blockly
* components on the page should use the focus functions in this manager.
*
* The manager is responsible for handling focus events from the DOM (which may
* may arise from users clicking on page elements) and ensuring that
* corresponding IFocusableNodes are clearly marked as actively/passively
* highlighted in the same way that this would be represented with calls to
* focusNode().
*/
export class FocusManager {
/**
* The CSS class assigned to IFocusableNode elements that presently have
* active DOM and Blockly focus.
*
* This should never be used directly. Instead, rely on FocusManager to ensure
* nodes have active focus (either automatically through DOM focus or manually
* through the various focus* methods provided by this class).
*
* It's recommended to not query using this class name, either. Instead, use
* FocusableTreeTraverser or IFocusableTree's methods to find a specific node.
*/
static readonly ACTIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyActiveFocus';
/**
* The CSS class assigned to IFocusableNode elements that presently have
* passive focus (that is, they were the most recent node in their relative
* tree to have active focus--see ACTIVE_FOCUS_NODE_CSS_CLASS_NAME--and will
* receive active focus again if their surrounding tree is requested to become
* focused, i.e. using focusTree below).
*
* See ACTIVE_FOCUS_NODE_CSS_CLASS_NAME for caveats and limitations around
* using this constant directly (generally it never should need to be used).
*/
static readonly PASSIVE_FOCUS_NODE_CSS_CLASS_NAME = 'blocklyPassiveFocus';
private focusedNode: IFocusableNode | null = null;
private previouslyFocusedNode: IFocusableNode | null = null;
private registeredTrees: Array<IFocusableTree> = [];
private currentlyHoldsEphemeralFocus: boolean = false;
private lockFocusStateChanges: boolean = false;
private recentlyLostAllFocus: boolean = false;
constructor(
addGlobalEventListener: (type: string, listener: EventListener) => void,
) {
// Note that 'element' here is the element *gaining* focus.
const maybeFocus = (element: Element | EventTarget | null) => {
this.recentlyLostAllFocus = !element;
let newNode: IFocusableNode | null | undefined = null;
if (element instanceof HTMLElement || element instanceof SVGElement) {
// If the target losing or gaining focus maps to any tree, then it
// should be updated. Per the contract of findFocusableNodeFor only one
// tree should claim the element, so the search can be exited early.
for (const tree of this.registeredTrees) {
newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree);
if (newNode) break;
}
}
if (newNode && newNode.canBeFocused()) {
const newTree = newNode.getFocusableTree();
const oldTree = this.focusedNode?.getFocusableTree();
if (newNode === newTree.getRootFocusableNode() && newTree !== oldTree) {
// If the root of the tree is the one taking focus (such as due to
// being tabbed), try to focus the whole tree explicitly to ensure the
// correct node re-receives focus.
this.focusTree(newTree);
} else {
this.focusNode(newNode);
}
} else {
this.defocusCurrentFocusedNode();
}
};
// Register root document focus listeners for tracking when focus leaves all
// tracked focusable trees. Note that focusin and focusout can be somewhat
// overlapping in the information that they provide. This is fine because
// they both aim to check for focus changes on the element gaining or having
// received focus, and maybeFocus should behave relatively deterministic.
addGlobalEventListener('focusin', (event) => {
if (!(event instanceof FocusEvent)) return;
// When something receives focus, always use the current active element as
// the source of truth.
maybeFocus(document.activeElement);
});
addGlobalEventListener('focusout', (event) => {
if (!(event instanceof FocusEvent)) return;
// When something loses focus, it seems that document.activeElement may
// not necessarily be correct. Instead, use relatedTarget.
maybeFocus(event.relatedTarget);
});
}
/**
* Registers a new IFocusableTree for automatic focus management.
*
* If the tree currently has an element with DOM focus, it will not affect the
* internal state in this manager until the focus changes to a new,
* now-monitored element/node.
*
* This function throws if the provided tree is already currently registered
* in this manager. Use isRegistered to check in cases when it can't be
* certain whether the tree has been registered.
*/
registerTree(tree: IFocusableTree): void {
this.ensureManagerIsUnlocked();
if (this.isRegistered(tree)) {
throw Error(`Attempted to re-register already registered tree: ${tree}.`);
}
this.registeredTrees.push(tree);
}
/**
* Returns whether the specified tree has already been registered in this
* manager using registerTree and hasn't yet been unregistered using
* unregisterTree.
*/
isRegistered(tree: IFocusableTree): boolean {
return this.registeredTrees.findIndex((reg) => reg === tree) !== -1;
}
/**
* Unregisters a IFocusableTree from automatic focus management.
*
* If the tree had a previous focused node, it will have its highlight
* removed. This function does NOT change DOM focus.
*
* This function throws if the provided tree is not currently registered in
* this manager.
*/
unregisterTree(tree: IFocusableTree): void {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(tree)) {
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
}
const treeIndex = this.registeredTrees.findIndex((reg) => reg === tree);
this.registeredTrees.splice(treeIndex, 1);
const focusedNode = FocusableTreeTraverser.findFocusedNode(tree);
const root = tree.getRootFocusableNode();
if (focusedNode) this.removeHighlight(focusedNode);
if (this.focusedNode === focusedNode || this.focusedNode === root) {
this.updateFocusedNode(null);
}
this.removeHighlight(root);
}
/**
* Returns the current IFocusableTree that has focus, or null if none
* currently do.
*
* Note also that if ephemeral focus is currently captured (e.g. using
* takeEphemeralFocus) then the returned tree here may not currently have DOM
* focus.
*/
getFocusedTree(): IFocusableTree | null {
return this.focusedNode?.getFocusableTree() ?? null;
}
/**
* Returns the current IFocusableNode with focus (which is always tied to a
* focused IFocusableTree), or null if there isn't one.
*
* Note that this function will maintain parity with
* IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but
* none of its non-root children do, this will return null but
* getFocusedTree() will not.
*
* Note also that if ephemeral focus is currently captured (e.g. using
* takeEphemeralFocus) then the returned node here may not currently have DOM
* focus.
*/
getFocusedNode(): IFocusableNode | null {
return this.focusedNode;
}
/**
* Focuses the specific IFocusableTree. This either means restoring active
* focus to the tree's passively focused node, or focusing the tree's root
* node.
*
* Note that if the specified tree already has a focused node then this will
* not change any existing focus (unless that node has passive focus, then it
* will be restored to active focus).
*
* See getFocusedNode for details on how other nodes are affected.
*
* @param focusableTree The tree that should receive active
* focus.
*/
focusTree(focusableTree: IFocusableTree): void {
this.ensureManagerIsUnlocked();
if (!this.isRegistered(focusableTree)) {
throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`);
}
const currNode = FocusableTreeTraverser.findFocusedNode(focusableTree);
const nodeToRestore = focusableTree.getRestoredFocusableNode(currNode);
const rootFallback = focusableTree.getRootFocusableNode();
this.focusNode(nodeToRestore ?? currNode ?? rootFallback);
}
/**
* Focuses DOM input on the specified node, and marks it as actively focused.
*
* Any previously focused node will be updated to be passively highlighted (if
* it's in a different focusable tree) or blurred (if it's in the same one).
*
* **Important**: If the provided node is not able to be focused (e.g. its
* canBeFocused() method returns false), it will be ignored and any existing
* focus state will remain unchanged.
*
* @param focusableNode The node that should receive active focus.
*/
focusNode(focusableNode: IFocusableNode): void {
this.ensureManagerIsUnlocked();
if (this.focusedNode === focusableNode) return; // State is unchanged.
if (!focusableNode.canBeFocused()) {
// This node can't be focused.
console.warn("Trying to focus a node that can't be focused.");
return;
}
const nextTree = focusableNode.getFocusableTree();
if (!this.isRegistered(nextTree)) {
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
}
// Safety check for ensuring focusNode() doesn't get called for a node that
// isn't actually hooked up to its parent tree correctly. This usually
// happens when calls to focusNode() interleave with asynchronous clean-up
// operations (which can happen due to ephemeral focus and in other cases).
// Fall back to a reasonable default since there's no valid node to focus.
const matchedNode = FocusableTreeTraverser.findFocusableNodeFor(
focusableNode.getFocusableElement(),
nextTree,
);
const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree);
let nodeToFocus = focusableNode;
if (matchedNode !== focusableNode) {
const nodeToRestore = nextTree.getRestoredFocusableNode(prevNodeNextTree);
const rootFallback = nextTree.getRootFocusableNode();
nodeToFocus = nodeToRestore ?? prevNodeNextTree ?? rootFallback;
}
const prevNode = this.focusedNode;
const prevTree = prevNode?.getFocusableTree();
if (prevNode) {
this.passivelyFocusNode(prevNode, nextTree);
}
// If there's a focused node in the new node's tree, ensure it's reset.
const nextTreeRoot = nextTree.getRootFocusableNode();
if (prevNodeNextTree) {
this.removeHighlight(prevNodeNextTree);
}
// For caution, ensure that the root is always reset since getFocusedNode()
// is expected to return null if the root was highlighted, if the root is
// not the node now being set to active.
if (nextTreeRoot !== nodeToFocus) {
this.removeHighlight(nextTreeRoot);
}
if (!this.currentlyHoldsEphemeralFocus) {
// Only change the actively focused node if ephemeral state isn't held.
this.activelyFocusNode(nodeToFocus, prevTree ?? null);
}
this.updateFocusedNode(nodeToFocus);
}
/**
* Ephemerally captures focus for a specific element until the returned lambda
* is called. This is expected to be especially useful for ephemeral UI flows
* like dialogs.
*
* IMPORTANT: the returned lambda *must* be called, otherwise automatic focus
* will no longer work anywhere on the page. It is highly recommended to tie
* the lambda call to the closure of the corresponding UI so that if input is
* manually changed to an element outside of the ephemeral UI, the UI should
* close and automatic input restored. Note that this lambda must be called
* exactly once and that subsequent calls will throw an error.
*
* Note that the manager will continue to track DOM input signals even when
* ephemeral focus is active, but it won't actually change node state until
* the returned lambda is called. Additionally, only 1 ephemeral focus context
* can be active at any given time (attempting to activate more than one
* simultaneously will result in an error being thrown).
*/
takeEphemeralFocus(
focusableElement: HTMLElement | SVGElement,
): ReturnEphemeralFocus {
this.ensureManagerIsUnlocked();
if (this.currentlyHoldsEphemeralFocus) {
throw Error(
`Attempted to take ephemeral focus when it's already held, ` +
`with new element: ${focusableElement}.`,
);
}
this.currentlyHoldsEphemeralFocus = true;
if (this.focusedNode) {
this.passivelyFocusNode(this.focusedNode, null);
}
focusableElement.focus();
let hasFinishedEphemeralFocus = false;
return () => {
if (hasFinishedEphemeralFocus) {
throw Error(
`Attempted to finish ephemeral focus twice for element: ` +
`${focusableElement}.`,
);
}
hasFinishedEphemeralFocus = true;
this.currentlyHoldsEphemeralFocus = false;
if (this.focusedNode) {
this.activelyFocusNode(this.focusedNode, null);
// Even though focus was restored, check if it's lost again. It's
// possible for the browser to force focus away from all elements once
// the ephemeral element disappears. This ensures focus is restored.
const capturedNode = this.focusedNode;
setTimeout(() => {
// These checks are set up to minimize the risk that a legitimate
// focus change occurred within the delay that this would override.
if (
!this.focusedNode &&
this.previouslyFocusedNode === capturedNode &&
this.recentlyLostAllFocus
) {
this.focusNode(capturedNode);
}
}, 0);
}
};
}
/**
* Ensures that the manager is currently allowing operations that change its
* internal focus state (such as via focusNode()).
*
* If the manager is currently not allowing state changes, an exception is
* thrown.
*/
private ensureManagerIsUnlocked(): void {
if (this.lockFocusStateChanges) {
throw Error(
'FocusManager state changes cannot happen in a tree/node focus/blur ' +
'callback.',
);
}
}
/**
* Updates the internally tracked focused node to the specified node, or null
* if focus is being lost. This also updates previous focus tracking.
*
* @param newFocusedNode The new node to set as focused.
*/
private updateFocusedNode(newFocusedNode: IFocusableNode | null) {
this.previouslyFocusedNode = this.focusedNode;
this.focusedNode = newFocusedNode;
}
/**
* Defocuses the current actively focused node tracked by the manager, iff
* there's a node being tracked and the manager doesn't have ephemeral focus.
*/
private defocusCurrentFocusedNode(): void {
// The current node will likely be defocused while ephemeral focus is held,
// but internal manager state shouldn't change since the node should be
// restored upon exiting ephemeral focus mode.
if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) {
this.passivelyFocusNode(this.focusedNode, null);
this.updateFocusedNode(null);
}
}
/**
* Marks the specified node as actively focused, also calling related
* lifecycle callback methods for both the node and its parent tree. This
* ensures that the node is properly styled to indicate its active focus.
*
* This does not change the manager's currently tracked node, nor does it
* change any other nodes.
*
* @param node The node to be actively focused.
* @param prevTree The tree of the previously actively focused node, or null
* if there wasn't a previously actively focused node.
*/
private activelyFocusNode(
node: IFocusableNode,
prevTree: IFocusableTree | null,
): void {
// Note that order matters here. Focus callbacks are allowed to change
// element visibility which can influence focusability, including for a
// node's focusable element (which *is* allowed to be invisible until the
// node needs to be focused).
this.lockFocusStateChanges = true;
if (node.getFocusableTree() !== prevTree) {
node.getFocusableTree().onTreeFocus(node, prevTree);
}
node.onNodeFocus();
this.lockFocusStateChanges = false;
this.setNodeToVisualActiveFocus(node);
node.getFocusableElement().focus();
}
/**
* Marks the specified node as passively focused, also calling related
* lifecycle callback methods for both the node and its parent tree. This
* ensures that the node is properly styled to indicate its passive focus.
*
* This does not change the manager's currently tracked node, nor does it
* change any other nodes.
*
* @param node The node to be passively focused.
* @param nextTree The tree of the node receiving active focus, or null if no
* node will be actively focused.
*/
private passivelyFocusNode(
node: IFocusableNode,
nextTree: IFocusableTree | null,
): void {
this.lockFocusStateChanges = true;
if (node.getFocusableTree() !== nextTree) {
node.getFocusableTree().onTreeBlur(nextTree);
}
node.onNodeBlur();
this.lockFocusStateChanges = false;
if (node.getFocusableTree() !== nextTree) {
this.setNodeToVisualPassiveFocus(node);
}
}
/**
* Updates the node's styling to indicate that it should have an active focus
* indicator.
*
* @param node The node to be styled for active focus.
*/
private setNodeToVisualActiveFocus(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.addClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
}
/**
* Updates the node's styling to indicate that it should have a passive focus
* indicator.
*
* @param node The node to be styled for passive focus.
*/
private setNodeToVisualPassiveFocus(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.addClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
}
/**
* Removes any active/passive indicators for the specified node.
*
* @param node The node which should have neither passive nor active focus
* indication.
*/
private removeHighlight(node: IFocusableNode): void {
const element = node.getFocusableElement();
dom.removeClass(element, FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME);
dom.removeClass(element, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME);
}
private static focusManager: FocusManager | null = null;
/**
* Returns the page-global FocusManager.
*
* The returned instance is guaranteed to not change across function calls,
* but may change across page loads.
*/
static getFocusManager(): FocusManager {
if (!FocusManager.focusManager) {
FocusManager.focusManager = new FocusManager(document.addEventListener);
}
return FocusManager.focusManager;
}
}
/** Convenience function for FocusManager.getFocusManager. */
export function getFocusManager(): FocusManager {
return FocusManager.getFocusManager();
}

View File

@@ -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(

View File

@@ -25,6 +25,7 @@ import * as dropDownDiv from './dropdowndiv.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import type {Field} from './field.js';
import {getFocusManager} from './focus_manager.js';
import type {IBubble} from './interfaces/i_bubble.js';
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {IDragger} from './interfaces/i_dragger.js';
@@ -289,7 +290,7 @@ export class Gesture {
// The start block is no longer relevant, because this is a drag.
this.startBlock = null;
this.targetBlock = this.flyout.createBlock(this.targetBlock);
common.setSelected(this.targetBlock);
getFocusManager().focusNode(this.targetBlock);
return true;
}
return false;
@@ -734,6 +735,7 @@ export class Gesture {
this.startComment.showContextMenu(e);
} else if (this.startWorkspace_ && !this.flyout) {
this.startWorkspace_.hideChaff();
getFocusManager().focusNode(this.startWorkspace_);
this.startWorkspace_.showContextMenu(e);
}
@@ -762,9 +764,12 @@ export class Gesture {
this.mostRecentEvent = e;
if (!this.startBlock && !this.startBubble && !this.startComment) {
// Selection determines what things start drags. So to drag the workspace,
// we need to deselect anything that was previously selected.
common.setSelected(null);
// Ensure the workspace is selected if nothing else should be. Note that
// this is focusNode() instead of focusTree() because if any active node
// is focused in the workspace it should be defocused.
getFocusManager().focusNode(ws);
} else if (this.startBlock) {
getFocusManager().focusNode(this.startBlock);
}
this.doStart(e);
@@ -865,13 +870,18 @@ export class Gesture {
);
}
// Note that the order is important here: bringing a block to the front will
// cause it to become focused and showing the field editor will capture
// focus ephemerally. It's important to ensure that focus is properly
// restored back to the block after field editing has completed.
this.bringBlockToFront();
// Only show the editor if the field's editor wasn't already open
// right before this gesture started.
const dropdownAlreadyOpen = this.currentDropdownOwner === this.startField;
if (!dropdownAlreadyOpen) {
this.startField.showEditor(this.mostRecentEvent);
}
this.bringBlockToFront();
}
/** Execute an icon click. */
@@ -894,13 +904,16 @@ export class Gesture {
'Cannot do a block click because the target block is ' + 'undefined',
);
}
if (this.targetBlock.isEnabled()) {
if (this.flyout.isBlockCreatable(this.targetBlock)) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
const newBlock = this.flyout.createBlock(this.targetBlock);
newBlock.snapToGrid();
newBlock.bumpNeighbours();
// If a new block was added, make sure that it's correctly focused.
getFocusManager().focusNode(newBlock);
}
} else {
if (!this.startWorkspace_) {
@@ -928,11 +941,7 @@ export class Gesture {
* @param _e A pointerup event.
*/
private doWorkspaceClick(_e: PointerEvent) {
const ws = this.creatorWorkspace;
if (common.getSelected()) {
common.getSelected()!.unselect();
}
this.fireWorkspaceClick(this.startWorkspace_ || ws);
this.fireWorkspaceClick(this.startWorkspace_ || this.creatorWorkspace);
}
/* End functions defining what actions to take to execute clicks on each type
@@ -947,6 +956,8 @@ export class Gesture {
private bringBlockToFront() {
// Blocks in the flyout don't overlap, so skip the work.
if (this.targetBlock && !this.flyout) {
// Always ensure the block being dragged/clicked has focus.
getFocusManager().focusNode(this.targetBlock);
this.targetBlock.bringToFront();
}
}
@@ -1023,7 +1034,6 @@ export class Gesture {
// If the gesture already went through a bubble, don't set the start block.
if (!this.startBlock && !this.startBubble) {
this.startBlock = block;
common.setSelected(this.startBlock);
if (block.isInFlyout && block !== block.getRootBlock()) {
this.setTargetBlock(block.getRootBlock());
} else {
@@ -1046,6 +1056,7 @@ export class Gesture {
this.setTargetBlock(block.getParent()!);
} else {
this.targetBlock = block;
getFocusManager().focusNode(block);
}
}

View File

@@ -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 {
/*
<pattern id="blocklyGridPattern837493" patternUnits="userSpaceOnUse">
@@ -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;
}
}

View File

@@ -11,6 +11,7 @@ import type {BlockSvg} from '../block_svg.js';
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import type {ISerializable} from '../interfaces/i_serializable.js';
import * as renderManagement from '../render_management.js';
@@ -55,6 +56,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
/** The size of this comment (which is applied to the editable bubble). */
private bubbleSize = new Size(DEFAULT_BUBBLE_WIDTH, DEFAULT_BUBBLE_HEIGHT);
/** The location of the comment bubble in workspace coordinates. */
private bubbleLocation?: Coordinate;
/**
* The visibility of the bubble for this comment.
*
@@ -108,7 +112,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
},
this.svgRoot,
);
dom.addClass(this.svgRoot!, 'blockly-icon-comment');
dom.addClass(this.svgRoot!, 'blocklyCommentIcon');
}
override dispose() {
@@ -144,7 +148,13 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}
override onLocationChange(blockOrigin: Coordinate): void {
const oldLocation = this.workspaceLocation;
super.onLocationChange(blockOrigin);
if (this.bubbleLocation) {
const newLocation = this.workspaceLocation;
const delta = Coordinate.difference(newLocation, oldLocation);
this.bubbleLocation = Coordinate.sum(this.bubbleLocation, delta);
}
const anchorLocation = this.getAnchorLocation();
this.textInputBubble?.setAnchorLocation(anchorLocation);
}
@@ -184,18 +194,42 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
return this.bubbleSize;
}
/**
* Sets the location of the comment bubble in the workspace.
*/
setBubbleLocation(location: Coordinate) {
this.bubbleLocation = location;
this.textInputBubble?.moveDuringDrag(location);
}
/**
* @returns the location of the comment bubble in the workspace.
*/
getBubbleLocation(): Coordinate | undefined {
return this.bubbleLocation;
}
/**
* @returns the state of the comment as a JSON serializable value if the
* comment has text. Otherwise returns null.
*/
saveState(): CommentState | null {
if (this.text) {
return {
const state: CommentState = {
'text': this.text,
'pinned': this.bubbleIsVisible(),
'height': this.bubbleSize.height,
'width': this.bubbleSize.width,
};
const location = this.getBubbleLocation();
if (location) {
state['x'] = this.sourceBlock.workspace.RTL
? this.sourceBlock.workspace.getWidth() -
(location.x + this.bubbleSize.width)
: location.x;
state['y'] = location.y;
}
return state;
}
return null;
}
@@ -209,6 +243,16 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
);
this.bubbleVisiblity = state['pinned'] ?? false;
this.setBubbleVisible(this.bubbleVisiblity);
let x = state['x'];
const y = state['y'];
renderManagement.finishQueuedRenders().then(() => {
if (x && y) {
x = this.sourceBlock.workspace.RTL
? this.sourceBlock.workspace.getWidth() - (x + this.bubbleSize.width)
: x;
this.setBubbleLocation(new Coordinate(x, y));
}
});
}
override onClick(): void {
@@ -252,6 +296,12 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}
}
onBubbleLocationChange(): void {
if (this.textInputBubble) {
this.bubbleLocation = this.textInputBubble.getRelativeToSurfaceXY();
}
}
bubbleIsVisible(): boolean {
return this.bubbleVisiblity;
}
@@ -289,6 +339,11 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
);
}
/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
return this.textInputBubble;
}
/**
* Shows the editable text bubble for this comment, and adds change listeners
* to update the state of this icon in response to changes in the bubble.
@@ -313,6 +368,14 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
);
this.textInputBubble.setText(this.getText());
this.textInputBubble.setSize(this.bubbleSize, true);
if (this.bubbleLocation) {
this.textInputBubble.moveDuringDrag(this.bubbleLocation);
}
this.textInputBubble.addTextChangeListener(() => this.onTextChange());
this.textInputBubble.addSizeChangeListener(() => this.onSizeChange());
this.textInputBubble.addLocationChangeListener(() =>
this.onBubbleLocationChange(),
);
}
/** Hides any open bubbles owned by this comment. */
@@ -355,6 +418,12 @@ export interface CommentState {
/** The width of the comment bubble. */
width?: number;
/** The X coordinate of the comment bubble. */
x?: number;
/** The Y coordinate of the comment bubble. */
y?: number;
}
registry.register(CommentIcon.TYPE, CommentIcon);

View File

@@ -7,13 +7,16 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import * as browserEvents from '../browser_events.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import type {IIcon} from '../interfaces/i_icon.js';
import * as tooltip from '../tooltip.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {IconType} from './icon_types.js';
/**
@@ -38,8 +41,12 @@ export abstract class Icon implements IIcon {
/** The tooltip for this icon. */
protected tooltip: tooltip.TipInfo;
/** The unique ID of this icon. */
private id: string;
constructor(protected sourceBlock: Block) {
this.tooltip = sourceBlock;
this.id = idGenerator.getNextUniqueId();
}
getType(): IconType<IIcon> {
@@ -50,7 +57,11 @@ export abstract class Icon implements IIcon {
if (this.svgRoot) return; // The icon has already been initialized.
const svgBlock = this.sourceBlock as BlockSvg;
this.svgRoot = dom.createSvgElement(Svg.G, {'class': 'blocklyIconGroup'});
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyIconGroup',
'tabindex': '-1',
'id': this.id,
});
svgBlock.getSvgRoot().appendChild(this.svgRoot);
this.updateSvgRootOffset();
browserEvents.conditionalBind(
@@ -144,4 +155,27 @@ export abstract class Icon implements IIcon {
isClickableInFlyout(autoClosingFlyout: boolean): boolean {
return true;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
const svgRoot = this.svgRoot;
if (!svgRoot) throw new Error('Attempting to focus uninitialized icon.');
return svgRoot;
}
/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
return this.sourceBlock.workspace as WorkspaceSvg;
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return true;
}
}

View File

@@ -14,6 +14,7 @@ import {BlockChange} from '../events/events_block_change.js';
import {isBlockChange, isBlockCreate} from '../events/predicates.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -118,7 +119,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
{'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'},
this.svgRoot,
);
dom.addClass(this.svgRoot!, 'blockly-icon-mutator');
dom.addClass(this.svgRoot!, 'blocklyMutatorIcon');
}
override dispose(): void {
@@ -203,6 +204,11 @@ export class MutatorIcon extends Icon implements IHasBubble {
);
}
/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
return this.miniWorkspaceBubble;
}
/** @returns the configuration the mini workspace should have. */
private getMiniWorkspaceConfig() {
const options: BlocklyOptions = {

View File

@@ -10,6 +10,7 @@ import type {BlockSvg} from '../block_svg.js';
import {TextBubble} from '../bubbles/text_bubble.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import * as renderManagement from '../render_management.js';
import {Size} from '../utils.js';
@@ -90,7 +91,7 @@ export class WarningIcon extends Icon implements IHasBubble {
},
this.svgRoot,
);
dom.addClass(this.svgRoot!, 'blockly-icon-warning');
dom.addClass(this.svgRoot!, 'blocklyWarningIcon');
}
override dispose() {
@@ -197,6 +198,11 @@ export class WarningIcon extends Icon implements IHasBubble {
);
}
/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
return this.textBubble;
}
/**
* @returns the location the bubble should be anchored to.
* I.E. the middle of this icon.

View File

@@ -13,13 +13,11 @@ import * as common from './common.js';
import * as Css from './css.js';
import * as dropDownDiv from './dropdowndiv.js';
import {Grid} from './grid.js';
import {Msg} from './msg.js';
import {Options} from './options.js';
import {ScrollbarPair} from './scrollbar_pair.js';
import {ShortcutRegistry} from './shortcut_registry.js';
import * as Tooltip from './tooltip.js';
import * as Touch from './touch.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import {Svg} from './utils/svg.js';
import * as WidgetDiv from './widgetdiv.js';
@@ -56,8 +54,6 @@ export function inject(
if (opt_options?.rtl) {
dom.addClass(subContainer, 'blocklyRTL');
}
subContainer.tabIndex = 0;
aria.setState(subContainer, aria.State.LABEL, Msg['WORKSPACE_ARIA_LABEL']);
containerElement!.appendChild(subContainer);
const svg = createDom(subContainer, options);
@@ -98,7 +94,7 @@ export function inject(
* @param options Dictionary of options.
* @returns Newly created SVG image.
*/
function createDom(container: Element, options: Options): SVGElement {
function createDom(container: HTMLElement, options: Options): SVGElement {
// Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying
// out content in RTL mode. Therefore Blockly forces the use of LTR,
// then manually positions content in RTL as needed.
@@ -126,7 +122,6 @@ function createDom(container: Element, options: Options): SVGElement {
'xmlns:xlink': dom.XLINK_NS,
'version': '1.1',
'class': 'blocklySvg',
'tabindex': '0',
},
container,
);
@@ -141,7 +136,12 @@ function createDom(container: Element, options: Options): SVGElement {
// https://neil.fraser.name/news/2015/11/01/
const rnd = String(Math.random()).substring(2);
options.gridPattern = Grid.createDom(rnd, options.gridOptions, defs);
options.gridPattern = Grid.createDom(
rnd,
options.gridOptions,
defs,
container,
);
return svg;
}
@@ -153,7 +153,7 @@ function createDom(container: Element, options: Options): SVGElement {
* @returns Newly created main workspace.
*/
function createMainWorkspace(
injectionDiv: Element,
injectionDiv: HTMLElement,
svg: SVGElement,
options: Options,
): WorkspaceSvg {

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -1,12 +0,0 @@
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.IASTNodeLocation
/**
* An AST node location interface.
*/
export interface IASTNodeLocation {}

View File

@@ -1,28 +0,0 @@
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.IASTNodeLocationSvg
import type {IASTNodeLocation} from './i_ast_node_location.js';
/**
* An AST node location SVG interface.
*/
export interface IASTNodeLocationSvg extends IASTNodeLocation {
/**
* Add the marker SVG to this node's SVG group.
*
* @param markerSvg The SVG root of the marker to be added to the SVG group.
*/
setMarkerSvg(markerSvg: SVGElement | null): void;
/**
* Add the cursor SVG to this node's SVG group.
*
* @param cursorSvg The SVG root of the cursor to be added to the SVG group.
*/
setCursorSvg(cursorSvg: SVGElement | null): void;
}

View File

@@ -1,22 +0,0 @@
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.IASTNodeLocationWithBlock
import type {Block} from '../block.js';
import type {IASTNodeLocation} from './i_ast_node_location.js';
/**
* An AST node location that has an associated block.
*/
export interface IASTNodeLocationWithBlock extends IASTNodeLocation {
/**
* Get the source block associated with this node.
*
* @returns The source block.
*/
getSourceBlock(): Block | null;
}

View File

@@ -20,3 +20,8 @@ export interface IAutoHideable extends IComponent {
*/
autoHide(onlyClosePopups: boolean): void;
}
/** Returns true if the given object is autohideable. */
export function isAutoHideable(obj: any): obj is IAutoHideable {
return obj.autoHide !== undefined;
}

View File

@@ -9,11 +9,12 @@
import type {Coordinate} from '../utils/coordinate.js';
import type {IContextMenu} from './i_contextmenu.js';
import type {IDraggable} from './i_draggable.js';
import {IFocusableNode} from './i_focusable_node.js';
/**
* A bubble interface.
*/
export interface IBubble extends IDraggable, IContextMenu {
export interface IBubble extends IDraggable, IContextMenu, IFocusableNode {
/**
* Return the coordinates of the top-left corner of this bubble's body
* relative to the drawing surface's origin (0,0), in workspace units.

View File

@@ -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
);
}

View File

@@ -7,17 +7,18 @@
// Former goog.module ID: Blockly.IFlyout
import type {BlockSvg} from '../block_svg.js';
import {FlyoutItem} from '../flyout_base.js';
import type {FlyoutItem} from '../flyout_item.js';
import type {Coordinate} from '../utils/coordinate.js';
import type {Svg} from '../utils/svg.js';
import type {FlyoutDefinition} from '../utils/toolbox.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {IFocusableTree} from './i_focusable_tree.js';
import type {IRegistrable} from './i_registrable.js';
/**
* Interface for a flyout.
*/
export interface IFlyout extends IRegistrable {
export interface IFlyout extends IRegistrable, IFocusableTree {
/** Whether the flyout is laid out horizontally or not. */
horizontalLayout: boolean;

View File

@@ -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;
}

View File

@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableTree} from './i_focusable_tree.js';
/** Represents anything that can have input focus. */
export interface IFocusableNode {
/**
* Returns the DOM element that can be explicitly requested to receive focus.
*
* IMPORTANT: Please note that this element is expected to have a visual
* presence on the page as it will both be explicitly focused and have its
* style changed depending on its current focus state (i.e. blurred, actively
* focused, and passively focused). The element will have one of two styles
* attached (where no style indicates blurred/not focused):
* - blocklyActiveFocus
* - blocklyPassiveFocus
*
* The returned element must also have a valid ID specified, and unique across
* the entire page. Failing to have a properly unique ID could result in
* trying to focus one node (such as via a mouse click) leading to another
* node with the same ID actually becoming focused by FocusManager. The
* returned element must also have a negative tabindex (since the focus
* manager itself will manage its tab index and a tab index must be present in
* order for the element to be focusable in the DOM).
*
* The returned element must be visible if the node is ever focused via
* FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an
* element to be hidden until onNodeFocus() is called, or become hidden with a
* call to onNodeBlur().
*
* It's expected the actual returned element will not change for the lifetime
* of the node (that is, its properties can change but a new element should
* never be returned).
*
* @returns The HTMLElement or SVGElement which can both receive focus and be
* visually represented as actively or passively focused for this node.
*/
getFocusableElement(): HTMLElement | SVGElement;
/**
* Returns the closest parent tree of this node (in cases where a tree has
* distinct trees underneath it), which represents the tree to which this node
* belongs.
*
* @returns The node's IFocusableTree.
*/
getFocusableTree(): IFocusableTree;
/**
* Called when this node receives active focus.
*
* Note that it's fine for implementations to change visibility modifiers, but
* they should avoid the following:
* - Creating or removing DOM elements (including via the renderer or drawer).
* - Affecting focus via DOM focus() calls or the FocusManager.
*/
onNodeFocus(): void;
/**
* Called when this node loses active focus. It may still have passive focus.
*
* This has the same implementation restrictions as onNodeFocus().
*/
onNodeBlur(): void;
/**
* Indicates whether this node allows focus. If this returns false then none
* of the other IFocusableNode methods will be called.
*
* Note that special care must be taken if implementations of this function
* dynamically change their return value value over the lifetime of the node
* as certain environment conditions could affect the focusability of this
* node's DOM element (such as whether the element has a positive or zero
* tabindex). Also, changing from a true to a false value while the node holds
* focus will not immediately change the current focus of the node nor
* FocusManager's internal state, and thus may result in some of the node's
* functions being called later on when defocused (since it was previously
* considered focusable at the time of being focused).
*
* Implementations should generally always return true here unless there are
* circumstances under which this node should be skipped for focus
* considerations. Examples may include being disabled, read-only, a purely
* visual decoration, or a node with no visual representation that must
* implement this interface (e.g. due to a parent interface extending it).
* Keep in mind accessibility best practices when determining whether a node
* should be focusable since even disabled and read-only elements are still
* often relevant to providing organizational context to users (particularly
* when using a screen reader).
*
* @returns Whether this node can be focused by FocusManager.
*/
canBeFocused(): boolean;
}
/**
* Determines whether the provided object fulfills the contract of
* IFocusableNode.
*
* @param object The object to test.
* @returns Whether the provided object can be used as an IFocusableNode.
*/
export function isFocusableNode(object: any | null): object is IFocusableNode {
return (
object &&
'getFocusableElement' in object &&
'getFocusableTree' in object &&
'onNodeFocus' in object &&
'onNodeBlur' in object &&
'canBeFocused' in object
);
}

View File

@@ -0,0 +1,144 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableNode} from './i_focusable_node.js';
/**
* Represents a tree of focusable elements with its own active/passive focus
* context.
*
* Note that focus is handled by FocusManager, and tree implementations can have
* at most one IFocusableNode focused at one time. If the tree itself has focus,
* then the tree's focused node is considered 'active' ('passive' if another
* tree has focus).
*
* Focus is shared between one or more trees, where each tree can have exactly
* one active or passive node (and only one active node can exist on the whole
* page at any given time). The idea of passive focus is to provide context to
* users on where their focus will be restored upon navigating back to a
* previously focused tree.
*
* Note that if the tree's current focused node (passive or active) is needed,
* FocusableTreeTraverser.findFocusedNode can be used.
*
* Note that if specific nodes are needed to be retrieved for this tree, either
* use lookUpFocusableNode or FocusableTreeTraverser.findFocusableNodeFor.
*/
export interface IFocusableTree {
/**
* Returns the top-level focusable node of the tree.
*
* It's expected that the returned node will be focused in cases where
* FocusManager wants to focus a tree in a situation where it does not
* currently have a focused node.
*/
getRootFocusableNode(): IFocusableNode;
/**
* Returns the IFocusableNode of this tree that should receive active focus
* when the tree itself has focus returned to it.
*
* There are some very important notes to consider about a tree's focus
* lifecycle when implementing a version of this method that doesn't return
* null:
* 1. A null previousNode does not guarantee first-time focus state as nodes
* can be deleted.
* 2. This method is only used when the tree itself is focused, either through
* tab navigation or via FocusManager.focusTree(). In many cases, the
* previously focused node will be directly focused instead which will
* bypass this method.
* 3. The default behavior (i.e. returning null here) involves either
* restoring the previous node (previousNode) or focusing the tree's root.
* 4. The provided node may sometimes no longer be valid, such as in the case
* an attempt is made to focus a node that has been recently removed from
* its parent tree. Implementations can check for the validity of the node
* in order to specialize the node to which focus should fall back.
*
* This method is largely intended to provide tree implementations with the
* means of specifying a better default node than their root.
*
* @param previousNode The node that previously held passive focus for this
* tree, or null if the tree hasn't yet been focused.
* @returns The IFocusableNode that should now receive focus, or null if
* default behavior should be used, instead.
*/
getRestoredFocusableNode(
previousNode: IFocusableNode | null,
): IFocusableNode | null;
/**
* Returns all directly nested trees under this tree.
*
* Note that the returned list of trees doesn't need to be stable, however all
* returned trees *do* need to be registered with FocusManager. Additionally,
* this must return actual nested trees as omitting a nested tree will affect
* how focus changes map to a specific node and its tree, potentially leading
* to user confusion.
*/
getNestedTrees(): Array<IFocusableTree>;
/**
* 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
);
}

View File

@@ -4,12 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {IBubble} from './i_bubble';
export interface IHasBubble {
/** @returns True if the bubble is currently open, false otherwise. */
bubbleIsVisible(): boolean;
/** Sets whether the bubble is open or not. */
setBubbleVisible(visible: boolean): Promise<void>;
/**
* Returns the current IBubble that implementations are managing, or null if
* there isn't one.
*
* Note that this cannot be expected to return null if bubbleIsVisible()
* returns false, i.e., the nullability of the returned bubble does not
* necessarily imply visibility.
*
* @returns The current IBubble maintained by implementations, or null if
* there is not one.
*/
getBubble(): IBubble | null;
}
/** Type guard that checks whether the given object is a IHasBubble. */

View File

@@ -7,8 +7,9 @@
import type {IconType} from '../icons/icon_types.js';
import type {Coordinate} from '../utils/coordinate.js';
import type {Size} from '../utils/size.js';
import {IFocusableNode, isFocusableNode} from './i_focusable_node.js';
export interface IIcon {
export interface IIcon extends IFocusableNode {
/**
* @returns the IconType representing the type of the icon. This value should
* also be used to register the icon via `Blockly.icons.registry.register`.
@@ -109,6 +110,7 @@ export function isIcon(obj: any): obj is IIcon {
obj.isShownWhenCollapsed !== undefined &&
obj.setOffsetInBlock !== undefined &&
obj.onLocationChange !== undefined &&
obj.onClick !== undefined
obj.onClick !== undefined &&
isFocusableNode(obj)
);
}

View File

@@ -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.
*/

View File

@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableNode} from './i_focusable_node.js';
/**
* A set of rules that specify where keyboard navigation should proceed.
*/
export interface INavigationPolicy<T> {
/**
* Returns the first child element of the given element, if any.
*
* @param current The element which the user is navigating into.
* @returns The current element's first child, or null if it has none.
*/
getFirstChild(current: T): IFocusableNode | null;
/**
* Returns the parent element of the given element, if any.
*
* @param current The element which the user is navigating out of.
* @returns The parent element of the current element, or null if it has none.
*/
getParent(current: T): IFocusableNode | null;
/**
* Returns the peer element following the given element, if any.
*
* @param current The element which the user is navigating past.
* @returns The next peer element of the current element, or null if there is
* none.
*/
getNextSibling(current: T): IFocusableNode | null;
/**
* Returns the peer element preceding the given element, if any.
*
* @param current The element which the user is navigating past.
* @returns The previous peer element of the current element, or null if
* there is none.
*/
getPreviousSibling(current: T): IFocusableNode | null;
/**
* Returns whether or not the given instance should be reachable via keyboard
* navigation.
*
* Implementors should generally return true, unless there are circumstances
* under which this item should be skipped while using keyboard navigation.
* Common examples might include being disabled, invalid, readonly, or purely
* a visual decoration. For example, while Fields are navigable, non-editable
* fields return false, since they cannot be interacted with when focused.
*
* @returns True if this element should be included in keyboard navigation.
*/
isNavigable(current: T): boolean;
/**
* Returns whether or not this navigation policy corresponds to the type of
* the given object.
*
* @param current An instance to check whether this policy applies to.
* @returns True if the given object is of a type handled by this policy.
*/
isApplicable(current: any): current is T;
}

View File

@@ -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;

View File

@@ -7,11 +7,17 @@
// Former goog.module ID: Blockly.ISelectable
import type {Workspace} from '../workspace.js';
import {IFocusableNode, isFocusableNode} from './i_focusable_node.js';
/**
* The interface for an object that is selectable.
*
* Implementations are generally expected to use their implementations of
* onNodeFocus() and onNodeBlur() to call setSelected() with themselves and
* null, respectively, in order to ensure that selections are correctly updated
* and the selection change event is fired.
*/
export interface ISelectable {
export interface ISelectable extends IFocusableNode {
id: string;
workspace: Workspace;
@@ -29,6 +35,7 @@ export function isSelectable(obj: object): obj is ISelectable {
typeof (obj as any).id === 'string' &&
(obj as any).workspace !== undefined &&
(obj as any).select !== undefined &&
(obj as any).unselect !== undefined
(obj as any).unselect !== undefined &&
isFocusableNode(obj)
);
}

View File

@@ -9,13 +9,14 @@
import type {ToolboxInfo} from '../utils/toolbox.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {IFlyout} from './i_flyout.js';
import type {IFocusableTree} from './i_focusable_tree.js';
import type {IRegistrable} from './i_registrable.js';
import type {IToolboxItem} from './i_toolbox_item.js';
/**
* Interface for a toolbox.
*/
export interface IToolbox extends IRegistrable {
export interface IToolbox extends IRegistrable, IFocusableTree {
/** Initializes the toolbox. */
init(): void;
@@ -94,7 +95,7 @@ export interface IToolbox extends IRegistrable {
setVisible(isVisible: boolean): void;
/**
* Selects the toolbox item by it's position in the list of toolbox items.
* Selects the toolbox item by its position in the list of toolbox items.
*
* @param position The position of the item to select.
*/
@@ -107,6 +108,14 @@ export interface IToolbox extends IRegistrable {
*/
getSelectedItem(): IToolboxItem | null;
/**
* Sets the selected item.
*
* @param item The toolbox item to select, or null to remove the current
* selection.
*/
setSelectedItem(item: IToolboxItem | null): void;
/** Disposes of this toolbox. */
dispose(): void;
}

View File

@@ -6,10 +6,12 @@
// Former goog.module ID: Blockly.IToolboxItem
import type {IFocusableNode} from './i_focusable_node.js';
/**
* Interface for an item in the toolbox.
*/
export interface IToolboxItem {
export interface IToolboxItem extends IFocusableNode {
/**
* Initializes the toolbox item.
* This includes creating the DOM and updating the state of any items based

View File

@@ -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<IVariableState>;
}
/**

View File

@@ -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<T extends IVariableModel<IVariableState>> {
/* 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;
}

View File

@@ -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<T extends IVariableState> {
/* 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<T extends IVariableState> {
new (
workspace: Workspace,
name: string,
type?: string,
id?: string,
): IVariableModel<T>;
/**
* 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<T>;
}
/**
* Represents the state of a given variable.
*/
export interface IVariableState {
name: string;
id: string;
type?: string;
}

View File

@@ -1,880 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The class representing an AST node.
* Used to traverse the Blockly AST.
*
* @class
*/
// Former goog.module ID: Blockly.ASTNode
import {Block} from '../block.js';
import type {Connection} from '../connection.js';
import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js';
import {FlyoutItem} from '../flyout_base.js';
import {FlyoutButton} from '../flyout_button.js';
import type {Input} from '../inputs/input.js';
import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js';
import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js';
import {Coordinate} from '../utils/coordinate.js';
import type {Workspace} from '../workspace.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* Class for an AST node.
* It is recommended that you use one of the createNode methods instead of
* creating a node directly.
*/
export class ASTNode {
/**
* True to navigate to all fields. False to only navigate to clickable fields.
*/
static NAVIGATE_ALL_FIELDS = false;
/**
* The default y offset to use when moving the cursor from a stack to the
* workspace.
*/
private static readonly DEFAULT_OFFSET_Y: number = -20;
private readonly type: string;
private readonly isConnectionLocation: boolean;
private readonly location: IASTNodeLocation;
/** The coordinate on the workspace. */
// AnyDuringMigration because: Type 'null' is not assignable to type
// 'Coordinate'.
private wsCoordinate: Coordinate = null as AnyDuringMigration;
/**
* @param type The type of the location.
* Must be in ASTNode.types.
* @param location The position in the AST.
* @param opt_params Optional dictionary of options.
*/
constructor(type: string, location: IASTNodeLocation, opt_params?: Params) {
if (!location) {
throw Error('Cannot create a node without a location.');
}
/**
* The type of the location.
* One of ASTNode.types
*/
this.type = type;
/** Whether the location points to a connection. */
this.isConnectionLocation = ASTNode.isConnectionType(type);
/** The location of the AST node. */
this.location = location;
this.processParams(opt_params || null);
}
/**
* Parse the optional parameters.
*
* @param params The user specified parameters.
*/
private processParams(params: Params | null) {
if (!params) {
return;
}
if (params.wsCoordinate) {
this.wsCoordinate = params.wsCoordinate;
}
}
/**
* Gets the value pointed to by this node.
* It is the callers responsibility to check the node type to figure out what
* type of object they get back from this.
*
* @returns The current field, connection, workspace, or block the cursor is
* on.
*/
getLocation(): IASTNodeLocation {
return this.location;
}
/**
* The type of the current location.
* One of ASTNode.types
*
* @returns The type of the location.
*/
getType(): string {
return this.type;
}
/**
* The coordinate on the workspace.
*
* @returns The workspace coordinate or null if the location is not a
* workspace.
*/
getWsCoordinate(): Coordinate {
return this.wsCoordinate;
}
/**
* Whether the node points to a connection.
*
* @returns [description]
* @internal
*/
isConnection(): boolean {
return this.isConnectionLocation;
}
/**
* Given an input find the next editable field or an input with a non null
* connection in the same block. The current location must be an input
* connection.
*
* @returns The AST node holding the next field or connection or null if there
* is no editable field or input connection after the given input.
*/
private findNextForInput(): ASTNode | null {
const location = this.location as Connection;
const parentInput = location.getParentInput();
const block = parentInput!.getSourceBlock();
// AnyDuringMigration because: Argument of type 'Input | null' is not
// assignable to parameter of type 'Input'.
const curIdx = block!.inputList.indexOf(parentInput as AnyDuringMigration);
for (let i = curIdx + 1; i < block!.inputList.length; i++) {
const input = block!.inputList[i];
const fieldRow = input.fieldRow;
for (let j = 0; j < fieldRow.length; j++) {
const field = fieldRow[j];
if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) {
return ASTNode.createFieldNode(field);
}
}
if (input.connection) {
return ASTNode.createInputNode(input);
}
}
return null;
}
/**
* Given a field find the next editable field or an input with a non null
* connection in the same block. The current location must be a field.
*
* @returns The AST node pointing to the next field or connection or null if
* there is no editable field or input connection after the given input.
*/
private findNextForField(): ASTNode | null {
const location = this.location as Field;
const input = location.getParentInput();
const block = location.getSourceBlock();
if (!block) {
throw new Error(
'The current AST location is not associated with a block',
);
}
const curIdx = block.inputList.indexOf(input);
let fieldIdx = input.fieldRow.indexOf(location) + 1;
for (let i = curIdx; i < block.inputList.length; i++) {
const newInput = block.inputList[i];
const fieldRow = newInput.fieldRow;
while (fieldIdx < fieldRow.length) {
if (fieldRow[fieldIdx].isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) {
return ASTNode.createFieldNode(fieldRow[fieldIdx]);
}
fieldIdx++;
}
fieldIdx = 0;
if (newInput.connection) {
return ASTNode.createInputNode(newInput);
}
}
return null;
}
/**
* Given an input find the previous editable field or an input with a non null
* connection in the same block. The current location must be an input
* connection.
*
* @returns The AST node holding the previous field or connection.
*/
private findPrevForInput(): ASTNode | null {
const location = this.location as Connection;
const parentInput = location.getParentInput();
const block = parentInput!.getSourceBlock();
// AnyDuringMigration because: Argument of type 'Input | null' is not
// assignable to parameter of type 'Input'.
const curIdx = block!.inputList.indexOf(parentInput as AnyDuringMigration);
for (let i = curIdx; i >= 0; i--) {
const input = block!.inputList[i];
if (input.connection && input !== parentInput) {
return ASTNode.createInputNode(input);
}
const fieldRow = input.fieldRow;
for (let j = fieldRow.length - 1; j >= 0; j--) {
const field = fieldRow[j];
if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) {
return ASTNode.createFieldNode(field);
}
}
}
return null;
}
/**
* Given a field find the previous editable field or an input with a non null
* connection in the same block. The current location must be a field.
*
* @returns The AST node holding the previous input or field.
*/
private findPrevForField(): ASTNode | null {
const location = this.location as Field;
const parentInput = location.getParentInput();
const block = location.getSourceBlock();
if (!block) {
throw new Error(
'The current AST location is not associated with a block',
);
}
const curIdx = block.inputList.indexOf(parentInput);
let fieldIdx = parentInput.fieldRow.indexOf(location) - 1;
for (let i = curIdx; i >= 0; i--) {
const input = block.inputList[i];
if (input.connection && input !== parentInput) {
return ASTNode.createInputNode(input);
}
const fieldRow = input.fieldRow;
while (fieldIdx > -1) {
if (fieldRow[fieldIdx].isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) {
return ASTNode.createFieldNode(fieldRow[fieldIdx]);
}
fieldIdx--;
}
// Reset the fieldIdx to the length of the field row of the previous
// input.
if (i - 1 >= 0) {
fieldIdx = block.inputList[i - 1].fieldRow.length - 1;
}
}
return null;
}
/**
* Navigate between stacks of blocks on the workspace.
*
* @param forward True to go forward. False to go backwards.
* @returns The first block of the next stack or null if there are no blocks
* on the workspace.
*/
private navigateBetweenStacks(forward: boolean): ASTNode | null {
let curLocation = this.getLocation();
// TODO(#6097): Use instanceof checks to exit early for values of
// curLocation that don't make sense.
if ((curLocation as IASTNodeLocationWithBlock).getSourceBlock) {
const block = (curLocation as IASTNodeLocationWithBlock).getSourceBlock();
if (block) {
curLocation = block;
}
}
// TODO(#6097): Use instanceof checks to exit early for values of
// curLocation that don't make sense.
const curLocationAsBlock = curLocation as Block;
if (!curLocationAsBlock || curLocationAsBlock.isDeadOrDying()) {
return null;
}
if (curLocationAsBlock.workspace.isFlyout) {
return this.navigateFlyoutContents(forward);
}
const curRoot = curLocationAsBlock.getRootBlock();
const topBlocks = curRoot.workspace.getTopBlocks(true);
for (let i = 0; i < topBlocks.length; i++) {
const topBlock = topBlocks[i];
if (curRoot.id === topBlock.id) {
const offset = forward ? 1 : -1;
const resultIndex = i + offset;
if (resultIndex === -1 || resultIndex === topBlocks.length) {
return null;
}
return ASTNode.createStackNode(topBlocks[resultIndex]);
}
}
throw Error(
"Couldn't find " + (forward ? 'next' : 'previous') + ' stack?!',
);
}
/**
* Navigate between buttons and stacks of blocks on the flyout workspace.
*
* @param forward True to go forward. False to go backwards.
* @returns The next button, or next stack's first block, or null
*/
private navigateFlyoutContents(forward: boolean): ASTNode | null {
const nodeType = this.getType();
let location;
let targetWorkspace;
switch (nodeType) {
case ASTNode.types.STACK: {
location = this.getLocation() as Block;
const workspace = location.workspace as WorkspaceSvg;
targetWorkspace = workspace.targetWorkspace as WorkspaceSvg;
break;
}
case ASTNode.types.BUTTON: {
location = this.getLocation() as FlyoutButton;
targetWorkspace = location.getTargetWorkspace() as WorkspaceSvg;
break;
}
default:
return null;
}
const flyout = targetWorkspace.getFlyout();
if (!flyout) return null;
const nextItem = this.findNextLocationInFlyout(
flyout.getContents(),
location,
forward,
);
if (!nextItem) return null;
if (nextItem.type === 'button' && nextItem.button) {
return ASTNode.createButtonNode(nextItem.button);
} else if (nextItem.type === 'block' && nextItem.block) {
return ASTNode.createStackNode(nextItem.block);
}
return null;
}
/**
* Finds the next (or previous if navigating backward) item in the flyout that should be navigated to.
*
* @param flyoutContents Contents of the current flyout.
* @param currentLocation Current ASTNode location.
* @param forward True if we're navigating forward, else false.
* @returns The next (or previous) FlyoutItem, or null if there is none.
*/
private findNextLocationInFlyout(
flyoutContents: FlyoutItem[],
currentLocation: IASTNodeLocation,
forward: boolean,
): FlyoutItem | null {
const currentIndex = flyoutContents.findIndex((item: FlyoutItem) => {
if (currentLocation instanceof Block && item.block === currentLocation) {
return true;
}
if (
currentLocation instanceof FlyoutButton &&
item.button === currentLocation
) {
return true;
}
return false;
});
if (currentIndex < 0) return null;
const resultIndex = forward ? currentIndex + 1 : currentIndex - 1;
if (resultIndex === -1 || resultIndex === flyoutContents.length) {
return null;
}
return flyoutContents[resultIndex];
}
/**
* Finds the top most AST node for a given block.
* This is either the previous connection, output connection or block
* depending on what kind of connections the block has.
*
* @param block The block that we want to find the top connection on.
* @returns The AST node containing the top connection.
*/
private findTopASTNodeForBlock(block: Block): ASTNode | null {
const topConnection = getParentConnection(block);
if (topConnection) {
return ASTNode.createConnectionNode(topConnection);
} else {
return ASTNode.createBlockNode(block);
}
}
/**
* Get the AST node pointing to the input that the block is nested under or if
* the block is not nested then get the stack AST node.
*
* @param block The source block of the current location.
* @returns The AST node pointing to the input connection or the top block of
* the stack this block is in.
*/
private getOutAstNodeForBlock(block: Block): ASTNode | null {
if (!block) {
return null;
}
// If the block doesn't have a previous connection then it is the top of the
// substack.
const topBlock = block.getTopStackBlock();
const topConnection = getParentConnection(topBlock);
// If the top connection has a parentInput, create an AST node pointing to
// that input.
if (
topConnection &&
topConnection.targetConnection &&
topConnection.targetConnection.getParentInput()
) {
// AnyDuringMigration because: Argument of type 'Input | null' is not
// assignable to parameter of type 'Input'.
return ASTNode.createInputNode(
topConnection.targetConnection.getParentInput() as AnyDuringMigration,
);
} else {
// Go to stack level if you are not underneath an input.
return ASTNode.createStackNode(topBlock);
}
}
/**
* Find the first editable field or input with a connection on a given block.
*
* @param block The source block of the current location.
* @returns An AST node pointing to the first field or input.
* Null if there are no editable fields or inputs with connections on the
* block.
*/
private findFirstFieldOrInput(block: Block): ASTNode | null {
const inputs = block.inputList;
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
const fieldRow = input.fieldRow;
for (let j = 0; j < fieldRow.length; j++) {
const field = fieldRow[j];
if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) {
return ASTNode.createFieldNode(field);
}
}
if (input.connection) {
return ASTNode.createInputNode(input);
}
}
return null;
}
/**
* Finds the source block of the location of this node.
*
* @returns The source block of the location, or null if the node is of type
* workspace or button.
*/
getSourceBlock(): Block | null {
if (this.getType() === ASTNode.types.BLOCK) {
return this.getLocation() as Block;
} else if (this.getType() === ASTNode.types.STACK) {
return this.getLocation() as Block;
} else if (this.getType() === ASTNode.types.WORKSPACE) {
return null;
} else if (this.getType() === ASTNode.types.BUTTON) {
return null;
} else {
return (this.getLocation() as IASTNodeLocationWithBlock).getSourceBlock();
}
}
/**
* Find the element to the right of the current element in the AST.
*
* @returns An AST node that wraps the next field, connection, block, or
* workspace. Or null if there is no node to the right.
*/
next(): ASTNode | null {
switch (this.type) {
case ASTNode.types.STACK:
return this.navigateBetweenStacks(true);
case ASTNode.types.OUTPUT: {
const connection = this.location as Connection;
return ASTNode.createBlockNode(connection.getSourceBlock());
}
case ASTNode.types.FIELD:
return this.findNextForField();
case ASTNode.types.INPUT:
return this.findNextForInput();
case ASTNode.types.BLOCK: {
const block = this.location as Block;
const nextConnection = block.nextConnection;
if (!nextConnection) return null;
return ASTNode.createConnectionNode(nextConnection);
}
case ASTNode.types.PREVIOUS: {
const connection = this.location as Connection;
return ASTNode.createBlockNode(connection.getSourceBlock());
}
case ASTNode.types.NEXT: {
const connection = this.location as Connection;
const targetConnection = connection.targetConnection;
return ASTNode.createConnectionNode(targetConnection!);
}
case ASTNode.types.BUTTON:
return this.navigateFlyoutContents(true);
}
return null;
}
/**
* Find the element one level below and all the way to the left of the current
* location.
*
* @returns An AST node that wraps the next field, connection, workspace, or
* block. Or null if there is nothing below this node.
*/
in(): ASTNode | null {
switch (this.type) {
case ASTNode.types.WORKSPACE: {
const workspace = this.location as Workspace;
const topBlocks = workspace.getTopBlocks(true);
if (topBlocks.length > 0) {
return ASTNode.createStackNode(topBlocks[0]);
}
break;
}
case ASTNode.types.STACK: {
const block = this.location as Block;
return this.findTopASTNodeForBlock(block);
}
case ASTNode.types.BLOCK: {
const block = this.location as Block;
return this.findFirstFieldOrInput(block);
}
case ASTNode.types.INPUT: {
const connection = this.location as Connection;
const targetConnection = connection.targetConnection;
return ASTNode.createConnectionNode(targetConnection!);
}
}
return null;
}
/**
* Find the element to the left of the current element in the AST.
*
* @returns An AST node that wraps the previous field, connection, workspace
* or block. Or null if no node exists to the left. null.
*/
prev(): ASTNode | null {
switch (this.type) {
case ASTNode.types.STACK:
return this.navigateBetweenStacks(false);
case ASTNode.types.OUTPUT:
return null;
case ASTNode.types.FIELD:
return this.findPrevForField();
case ASTNode.types.INPUT:
return this.findPrevForInput();
case ASTNode.types.BLOCK: {
const block = this.location as Block;
const topConnection = getParentConnection(block);
if (!topConnection) return null;
return ASTNode.createConnectionNode(topConnection);
}
case ASTNode.types.PREVIOUS: {
const connection = this.location as Connection;
const targetConnection = connection.targetConnection;
if (targetConnection && !targetConnection.getParentInput()) {
return ASTNode.createConnectionNode(targetConnection);
}
break;
}
case ASTNode.types.NEXT: {
const connection = this.location as Connection;
return ASTNode.createBlockNode(connection.getSourceBlock());
}
case ASTNode.types.BUTTON:
return this.navigateFlyoutContents(false);
}
return null;
}
/**
* Find the next element that is one position above and all the way to the
* left of the current location.
*
* @returns An AST node that wraps the next field, connection, workspace or
* block. Or null if we are at the workspace level.
*/
out(): ASTNode | null {
switch (this.type) {
case ASTNode.types.STACK: {
const block = this.location as Block;
const blockPos = block.getRelativeToSurfaceXY();
// TODO: Make sure this is in the bounds of the workspace.
const wsCoordinate = new Coordinate(
blockPos.x,
blockPos.y + ASTNode.DEFAULT_OFFSET_Y,
);
return ASTNode.createWorkspaceNode(block.workspace, wsCoordinate);
}
case ASTNode.types.OUTPUT: {
const connection = this.location as Connection;
const target = connection.targetConnection;
if (target) {
return ASTNode.createConnectionNode(target);
}
return ASTNode.createStackNode(connection.getSourceBlock());
}
case ASTNode.types.FIELD: {
const field = this.location as Field;
const block = field.getSourceBlock();
if (!block) {
throw new Error(
'The current AST location is not associated with a block',
);
}
return ASTNode.createBlockNode(block);
}
case ASTNode.types.INPUT: {
const connection = this.location as Connection;
return ASTNode.createBlockNode(connection.getSourceBlock());
}
case ASTNode.types.BLOCK: {
const block = this.location as Block;
return this.getOutAstNodeForBlock(block);
}
case ASTNode.types.PREVIOUS: {
const connection = this.location as Connection;
return this.getOutAstNodeForBlock(connection.getSourceBlock());
}
case ASTNode.types.NEXT: {
const connection = this.location as Connection;
return this.getOutAstNodeForBlock(connection.getSourceBlock());
}
}
return null;
}
/**
* Whether an AST node of the given type points to a connection.
*
* @param type The type to check. One of ASTNode.types.
* @returns True if a node of the given type points to a connection.
*/
private static isConnectionType(type: string): boolean {
switch (type) {
case ASTNode.types.PREVIOUS:
case ASTNode.types.NEXT:
case ASTNode.types.INPUT:
case ASTNode.types.OUTPUT:
return true;
}
return false;
}
/**
* Create an AST node pointing to a field.
*
* @param field The location of the AST node.
* @returns An AST node pointing to a field.
*/
static createFieldNode(field: Field): ASTNode | null {
if (!field) {
return null;
}
return new ASTNode(ASTNode.types.FIELD, field);
}
/**
* Creates an AST node pointing to a connection. If the connection has a
* parent input then create an AST node of type input that will hold the
* connection.
*
* @param connection This is the connection the node will point to.
* @returns An AST node pointing to a connection.
*/
static createConnectionNode(connection: Connection): ASTNode | null {
if (!connection) {
return null;
}
const type = connection.type;
if (type === ConnectionType.INPUT_VALUE) {
// AnyDuringMigration because: Argument of type 'Input | null' is not
// assignable to parameter of type 'Input'.
return ASTNode.createInputNode(
connection.getParentInput() as AnyDuringMigration,
);
} else if (
type === ConnectionType.NEXT_STATEMENT &&
connection.getParentInput()
) {
// AnyDuringMigration because: Argument of type 'Input | null' is not
// assignable to parameter of type 'Input'.
return ASTNode.createInputNode(
connection.getParentInput() as AnyDuringMigration,
);
} else if (type === ConnectionType.NEXT_STATEMENT) {
return new ASTNode(ASTNode.types.NEXT, connection);
} else if (type === ConnectionType.OUTPUT_VALUE) {
return new ASTNode(ASTNode.types.OUTPUT, connection);
} else if (type === ConnectionType.PREVIOUS_STATEMENT) {
return new ASTNode(ASTNode.types.PREVIOUS, connection);
}
return null;
}
/**
* Creates an AST node pointing to an input. Stores the input connection as
* the location.
*
* @param input The input used to create an AST node.
* @returns An AST node pointing to a input.
*/
static createInputNode(input: Input): ASTNode | null {
if (!input || !input.connection) {
return null;
}
return new ASTNode(ASTNode.types.INPUT, input.connection);
}
/**
* Creates an AST node pointing to a block.
*
* @param block The block used to create an AST node.
* @returns An AST node pointing to a block.
*/
static createBlockNode(block: Block): ASTNode | null {
if (!block) {
return null;
}
return new ASTNode(ASTNode.types.BLOCK, block);
}
/**
* Create an AST node of type stack. A stack, represented by its top block, is
* the set of all blocks connected to a top block, including the top
* block.
*
* @param topBlock A top block has no parent and can be found in the list
* returned by workspace.getTopBlocks().
* @returns An AST node of type stack that points to the top block on the
* stack.
*/
static createStackNode(topBlock: Block): ASTNode | null {
if (!topBlock) {
return null;
}
return new ASTNode(ASTNode.types.STACK, topBlock);
}
/**
* Create an AST node of type button. A button in this case refers
* specifically to a button in a flyout.
*
* @param button A top block has no parent and can be found in the list
* returned by workspace.getTopBlocks().
* @returns An AST node of type stack that points to the top block on the
* stack.
*/
static createButtonNode(button: FlyoutButton): ASTNode | null {
if (!button) {
return null;
}
return new ASTNode(ASTNode.types.BUTTON, button);
}
/**
* Creates an AST node pointing to a workspace.
*
* @param workspace The workspace that we are on.
* @param wsCoordinate The position on the workspace for this node.
* @returns An AST node pointing to a workspace and a position on the
* workspace.
*/
static createWorkspaceNode(
workspace: Workspace | null,
wsCoordinate: Coordinate | null,
): ASTNode | null {
if (!wsCoordinate || !workspace) {
return null;
}
const params = {wsCoordinate};
return new ASTNode(ASTNode.types.WORKSPACE, workspace, params);
}
/**
* Creates an AST node for the top position on a block.
* This is either an output connection, previous connection, or block.
*
* @param block The block to find the top most AST node on.
* @returns The AST node holding the top most position on the block.
*/
static createTopNode(block: Block): ASTNode | null {
let astNode;
const topConnection = getParentConnection(block);
if (topConnection) {
astNode = ASTNode.createConnectionNode(topConnection);
} else {
astNode = ASTNode.createBlockNode(block);
}
return astNode;
}
}
export namespace ASTNode {
export interface Params {
wsCoordinate: Coordinate;
}
export enum types {
FIELD = 'field',
BLOCK = 'block',
INPUT = 'input',
OUTPUT = 'output',
NEXT = 'next',
PREVIOUS = 'previous',
STACK = 'stack',
WORKSPACE = 'workspace',
BUTTON = 'button',
}
}
export type Params = ASTNode.Params;
// No need to export ASTNode.types from the module at this time because (1) it
// wasn't automatically converted by the automatic migration script, (2) the
// name doesn't follow the styleguide.
/**
* Gets the parent connection on a block.
* This is either an output connection, previous connection or undefined.
* If both connections exist return the one that is actually connected
* to another block.
*
* @param block The block to find the parent connection on.
* @returns The connection connecting to the parent of the block.
*/
function getParentConnection(block: Block): Connection | null {
let topConnection = block.outputConnection;
if (
!topConnection ||
(block.previousConnection && block.previousConnection.isConnected())
) {
topConnection = block.previousConnection;
}
return topConnection;
}

View File

@@ -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,
);

View File

@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {BlockSvg} from '../block_svg.js';
import type {Field} from '../field.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* Set of rules controlling keyboard navigation from a block.
*/
export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
/**
* Returns the first child of the given block.
*
* @param current The block to return the first child of.
* @returns The first field or input of the given block, if any.
*/
getFirstChild(current: BlockSvg): IFocusableNode | null {
for (const input of current.inputList) {
for (const field of input.fieldRow) {
return field;
}
if (input.connection?.targetBlock())
return input.connection.targetBlock() as BlockSvg;
}
return null;
}
/**
* Returns the parent of the given block.
*
* @param current The block to return the parent of.
* @returns The top block of the given block's stack, or the connection to
* which it is attached.
*/
getParent(current: BlockSvg): IFocusableNode | null {
if (current.previousConnection?.targetBlock()) {
const surroundParent = current.getSurroundParent();
if (surroundParent) return surroundParent;
} else if (current.outputConnection?.targetBlock()) {
return current.outputConnection.targetBlock();
}
return current.workspace;
}
/**
* Returns the next peer node of the given block.
*
* @param current The block to find the following element of.
* @returns The first block of the next stack if the given block is a terminal
* block, or its next connection.
*/
getNextSibling(current: BlockSvg): IFocusableNode | null {
if (current.nextConnection?.targetBlock()) {
return current.nextConnection?.targetBlock();
}
const parent = this.getParent(current);
let navigatingCrossStacks = false;
let siblings: (BlockSvg | Field)[] = [];
if (parent instanceof BlockSvg) {
for (let i = 0, input; (input = parent.inputList[i]); i++) {
if (input.connection) {
siblings.push(...input.fieldRow);
const child = input.connection.targetBlock();
if (child) {
siblings.push(child as BlockSvg);
}
}
}
} else if (parent instanceof WorkspaceSvg) {
siblings = parent.getTopBlocks(true);
navigatingCrossStacks = true;
} else {
return null;
}
const currentIndex = siblings.indexOf(
navigatingCrossStacks ? current.getRootBlock() : current,
);
if (currentIndex >= 0 && currentIndex < siblings.length - 1) {
return siblings[currentIndex + 1];
} else if (currentIndex === siblings.length - 1 && navigatingCrossStacks) {
return siblings[0];
}
return null;
}
/**
* Returns the previous peer node of the given block.
*
* @param current The block to find the preceding element of.
* @returns The block's previous/output connection, or the last
* connection/block of the previous block stack if it is a root block.
*/
getPreviousSibling(current: BlockSvg): IFocusableNode | null {
if (current.previousConnection?.targetBlock()) {
return current.previousConnection?.targetBlock();
}
const parent = this.getParent(current);
let navigatingCrossStacks = false;
let siblings: (BlockSvg | Field)[] = [];
if (parent instanceof BlockSvg) {
for (let i = 0, input; (input = parent.inputList[i]); i++) {
if (input.connection) {
siblings.push(...input.fieldRow);
const child = input.connection.targetBlock();
if (child) {
siblings.push(child as BlockSvg);
}
}
}
} else if (parent instanceof WorkspaceSvg) {
siblings = parent.getTopBlocks(true);
navigatingCrossStacks = true;
} else {
return null;
}
const currentIndex = siblings.indexOf(current);
let result: IFocusableNode | null = null;
if (currentIndex >= 1) {
result = siblings[currentIndex - 1];
} else if (currentIndex === 0 && navigatingCrossStacks) {
result = siblings[siblings.length - 1];
}
// If navigating to a previous stack, our previous sibling is the last
// block in it.
if (navigatingCrossStacks && result instanceof BlockSvg) {
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
}
return result;
}
/**
* Returns whether or not the given block can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given block can be focused.
*/
isNavigable(current: BlockSvg): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a BlockSvg.
*/
isApplicable(current: any): current is BlockSvg {
return current instanceof BlockSvg;
}
}

View File

@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from '../block_svg.js';
import {ConnectionType} from '../connection_type.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {RenderedConnection} from '../rendered_connection.js';
/**
* Set of rules controlling keyboard navigation from a connection.
*/
export class ConnectionNavigationPolicy
implements INavigationPolicy<RenderedConnection>
{
/**
* Returns the first child of the given connection.
*
* @param current The connection to return the first child of.
* @returns The connection's first child element, or null if not none.
*/
getFirstChild(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
return current.targetConnection;
}
return null;
}
/**
* Returns the parent of the given connection.
*
* @param current The connection to return the parent of.
* @returns The given connection's parent connection or block.
*/
getParent(current: RenderedConnection): IFocusableNode | null {
if (current.type === ConnectionType.OUTPUT_VALUE) {
return current.targetConnection ?? current.getSourceBlock();
} else if (current.getParentInput()) {
return current.getSourceBlock();
}
const topBlock = current.getSourceBlock().getTopStackBlock();
return (
(this.getParentConnection(topBlock)?.targetConnection?.getParentInput()
?.connection as RenderedConnection) ?? topBlock
);
}
/**
* Returns the next element following the given connection.
*
* @param current The connection to navigate from.
* @returns The field, input connection or block following this connection.
*/
getNextSibling(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
const parentInput = current.getParentInput();
const block = parentInput?.getSourceBlock();
if (!block || !parentInput) return null;
const curIdx = block.inputList.indexOf(parentInput);
for (let i = curIdx + 1; i < block.inputList.length; i++) {
const input = block.inputList[i];
const fieldRow = input.fieldRow;
if (fieldRow.length) return fieldRow[0];
if (input.connection) return input.connection as RenderedConnection;
}
return null;
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
const nextBlock = current.targetConnection;
// If this connection is the last one in the stack, our next sibling is
// the next block stack.
const sourceBlock = current.getSourceBlock();
if (
!nextBlock &&
sourceBlock.getRootBlock().lastConnectionInStack(false) === current
) {
const topBlocks = sourceBlock.workspace.getTopBlocks(true);
let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1;
if (targetIndex >= topBlocks.length) {
targetIndex = 0;
}
const nextBlock = topBlocks[targetIndex];
return this.getParentConnection(nextBlock) ?? nextBlock;
}
return nextBlock;
}
return current.getSourceBlock();
}
/**
* Returns the element preceding the given connection.
*
* @param current The connection to navigate from.
* @returns The field, input connection or block preceding this connection.
*/
getPreviousSibling(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
const parentInput = current.getParentInput();
const block = parentInput?.getSourceBlock();
if (!block || !parentInput) return null;
const curIdx = block.inputList.indexOf(parentInput);
for (let i = curIdx; i >= 0; i--) {
const input = block.inputList[i];
if (input.connection && input !== parentInput) {
return input.connection as RenderedConnection;
}
const fieldRow = input.fieldRow;
if (fieldRow.length) return fieldRow[fieldRow.length - 1];
}
return null;
} else if (
current.type === ConnectionType.PREVIOUS_STATEMENT ||
current.type === ConnectionType.OUTPUT_VALUE
) {
const previousConnection =
current.targetConnection && !current.targetConnection.getParentInput()
? current.targetConnection
: null;
// If this connection is a disconnected previous/output connection, our
// previous sibling is the previous block stack's last connection/block.
const sourceBlock = current.getSourceBlock();
if (
!previousConnection &&
this.getParentConnection(sourceBlock.getRootBlock()) === current
) {
const topBlocks = sourceBlock.workspace.getTopBlocks(true);
let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1;
if (targetIndex < 0) {
targetIndex = topBlocks.length - 1;
}
const previousRootBlock = topBlocks[targetIndex];
return (
previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock
);
}
return previousConnection;
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
return current.getSourceBlock();
}
return null;
}
/**
* Gets the parent connection on a block.
* This is either an output connection, previous connection or undefined.
* If both connections exist return the one that is actually connected
* to another block.
*
* @param block The block to find the parent connection on.
* @returns The connection connecting to the parent of the block.
*/
protected getParentConnection(block: BlockSvg) {
if (!block.outputConnection || block.previousConnection?.isConnected()) {
return block.previousConnection;
}
return block.outputConnection;
}
/**
* Returns whether or not the given connection can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given connection can be focused.
*/
isNavigable(current: RenderedConnection): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a RenderedConnection.
*/
isApplicable(current: any): current is RenderedConnection {
return current instanceof RenderedConnection;
}
}

View File

@@ -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);

View File

@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from '../block_svg.js';
import {Field} from '../field.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a field.
*/
export class FieldNavigationPolicy implements INavigationPolicy<Field<any>> {
/**
* Returns null since fields do not have children.
*
* @param _current The field to navigate from.
* @returns Null.
*/
getFirstChild(_current: Field<any>): IFocusableNode | null {
return null;
}
/**
* Returns the parent block of the given field.
*
* @param current The field to navigate from.
* @returns The given field's parent block.
*/
getParent(current: Field<any>): IFocusableNode | null {
return current.getSourceBlock() as BlockSvg;
}
/**
* Returns the next field or input following the given field.
*
* @param current The field to navigate from.
* @returns The next field or input in the given field's block.
*/
getNextSibling(current: Field<any>): IFocusableNode | null {
const input = current.getParentInput();
const block = current.getSourceBlock();
if (!block) return null;
const curIdx = block.inputList.indexOf(input);
let fieldIdx = input.fieldRow.indexOf(current) + 1;
for (let i = curIdx; i < block.inputList.length; i++) {
const newInput = block.inputList[i];
const fieldRow = newInput.fieldRow;
if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx];
fieldIdx = 0;
if (newInput.connection?.targetBlock()) {
return newInput.connection.targetBlock() as BlockSvg;
}
}
return null;
}
/**
* Returns the field or input preceding the given field.
*
* @param current The field to navigate from.
* @returns The preceding field or input in the given field's block.
*/
getPreviousSibling(current: Field<any>): IFocusableNode | null {
const parentInput = current.getParentInput();
const block = current.getSourceBlock();
if (!block) return null;
const curIdx = block.inputList.indexOf(parentInput);
let fieldIdx = parentInput.fieldRow.indexOf(current) - 1;
for (let i = curIdx; i >= 0; i--) {
const input = block.inputList[i];
if (input.connection?.targetBlock() && input !== parentInput) {
return input.connection.targetBlock() as BlockSvg;
}
const fieldRow = input.fieldRow;
if (fieldIdx > -1) return fieldRow[fieldIdx];
// Reset the fieldIdx to the length of the field row of the previous
// input.
if (i - 1 >= 0) {
fieldIdx = block.inputList[i - 1].fieldRow.length - 1;
}
}
return null;
}
/**
* Returns whether or not the given field can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given field can be focused and navigated to.
*/
isNavigable(current: Field<any>): boolean {
return (
current.canBeFocused() &&
(current.isClickable() || current.isCurrentlyEditable()) &&
!(
current.getSourceBlock()?.isSimpleReporter() &&
current.isFullBlockField()
) &&
current.getParentInput().isVisible()
);
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a Field.
*/
isApplicable(current: any): current is Field {
return current instanceof Field;
}
}

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {FlyoutButton} from '../flyout_button.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a flyout button.
*/
export class FlyoutButtonNavigationPolicy
implements INavigationPolicy<FlyoutButton>
{
/**
* Returns null since flyout buttons have no children.
*
* @param _current The FlyoutButton instance to navigate from.
* @returns Null.
*/
getFirstChild(_current: FlyoutButton): IFocusableNode | null {
return null;
}
/**
* Returns the parent workspace of the given flyout button.
*
* @param current The FlyoutButton instance to navigate from.
* @returns The given flyout button's parent workspace.
*/
getParent(current: FlyoutButton): IFocusableNode | null {
return current.getWorkspace();
}
/**
* Returns null since inter-item navigation is done by FlyoutNavigationPolicy.
*
* @param _current The FlyoutButton instance to navigate from.
* @returns Null.
*/
getNextSibling(_current: FlyoutButton): IFocusableNode | null {
return null;
}
/**
* Returns null since inter-item navigation is done by FlyoutNavigationPolicy.
*
* @param _current The FlyoutButton instance to navigate from.
* @returns Null.
*/
getPreviousSibling(_current: FlyoutButton): IFocusableNode | null {
return null;
}
/**
* Returns whether or not the given flyout button can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given flyout button can be focused.
*/
isNavigable(current: FlyoutButton): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a FlyoutButton.
*/
isApplicable(current: any): current is FlyoutButton {
return current instanceof FlyoutButton;
}
}

Some files were not shown because too many files have changed in this diff Show More