mirror of
https://github.com/google/blockly.git
synced 2026-01-06 08:30:13 +01:00
release: v12.0.0
This commit is contained in:
3
.github/workflows/assign_reviewers.yml
vendored
3
.github/workflows/assign_reviewers.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/conventional-label.yml
vendored
2
.github/workflows/conventional-label.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
141
core/block.ts
141
core/block.ts
@@ -40,25 +40,26 @@ import {EndRowInput} from './inputs/end_row_input.js';
|
||||
import {Input} from './inputs/input.js';
|
||||
import {StatementInput} from './inputs/statement_input.js';
|
||||
import {ValueInput} from './inputs/value_input.js';
|
||||
import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
|
||||
import {isCommentIcon} from './interfaces/i_comment_icon.js';
|
||||
import {type IIcon} from './interfaces/i_icon.js';
|
||||
import type {
|
||||
IVariableModel,
|
||||
IVariableState,
|
||||
} from './interfaces/i_variable_model.js';
|
||||
import * as registry from './registry.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import * as arrayUtils from './utils/array.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as deprecation from './utils/deprecation.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
import {Size} from './utils/size.js';
|
||||
import type {VariableModel} from './variable_model.js';
|
||||
import type {Workspace} from './workspace.js';
|
||||
|
||||
/**
|
||||
* Class for one block.
|
||||
* Not normally called directly, workspace.newBlock() is preferred.
|
||||
*/
|
||||
export class Block implements IASTNodeLocation {
|
||||
export class Block {
|
||||
/**
|
||||
* An optional callback method to use whenever the block's parent workspace
|
||||
* changes. This is usually only called from the constructor, the block type
|
||||
@@ -792,7 +793,7 @@ export class Block implements IASTNodeLocation {
|
||||
this.deletable &&
|
||||
!this.shadow &&
|
||||
!this.isDeadOrDying() &&
|
||||
!this.workspace.options.readOnly
|
||||
!this.workspace.isReadOnly()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -825,7 +826,7 @@ export class Block implements IASTNodeLocation {
|
||||
this.movable &&
|
||||
!this.shadow &&
|
||||
!this.isDeadOrDying() &&
|
||||
!this.workspace.options.readOnly
|
||||
!this.workspace.isReadOnly()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -914,7 +915,7 @@ export class Block implements IASTNodeLocation {
|
||||
*/
|
||||
isEditable(): boolean {
|
||||
return (
|
||||
this.editable && !this.isDeadOrDying() && !this.workspace.options.readOnly
|
||||
this.editable && !this.isDeadOrDying() && !this.workspace.isReadOnly()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -934,10 +935,8 @@ export class Block implements IASTNodeLocation {
|
||||
*/
|
||||
setEditable(editable: boolean) {
|
||||
this.editable = editable;
|
||||
for (let i = 0, input; (input = this.inputList[i]); i++) {
|
||||
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
|
||||
field.updateEditable();
|
||||
}
|
||||
for (const field of this.getFields()) {
|
||||
field.updateEditable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,16 +1103,27 @@ export class Block implements IASTNodeLocation {
|
||||
' instead',
|
||||
);
|
||||
}
|
||||
for (let i = 0, input; (input = this.inputList[i]); i++) {
|
||||
for (let j = 0, field; (field = input.fieldRow[j]); j++) {
|
||||
if (field.name === name) {
|
||||
return field;
|
||||
}
|
||||
for (const field of this.getFields()) {
|
||||
if (field.name === name) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a generator that provides every field on the block.
|
||||
*
|
||||
* @yields A generator that can be used to iterate the fields on the block.
|
||||
*/
|
||||
*getFields(): Generator<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.
|
||||
*/
|
||||
|
||||
283
core/block_flyout_inflater.ts
Normal file
283
core/block_flyout_inflater.ts
Normal 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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(`
|
||||
|
||||
76
core/button_flyout_inflater.ts
Normal file
76
core/button_flyout_inflater.ts
Normal 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,
|
||||
);
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
120
core/css.ts
120
core/css.ts
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
// Former goog.module ID: Blockly.Css
|
||||
|
||||
/** Has CSS already been injected? */
|
||||
let injected = false;
|
||||
|
||||
@@ -83,17 +82,15 @@ let content = `
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.blocklyNonSelectable {
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.blocklyBlockCanvas.blocklyCanvasTransitioning,
|
||||
.blocklyBubbleCanvas.blocklyCanvasTransitioning {
|
||||
transition: transform .5s;
|
||||
}
|
||||
|
||||
.blocklyEmboss {
|
||||
filter: var(--blocklyEmbossFilter);
|
||||
}
|
||||
|
||||
.blocklyTooltipDiv {
|
||||
background-color: #ffffc7;
|
||||
border: 1px solid #ddc;
|
||||
@@ -121,15 +118,12 @@ let content = `
|
||||
box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
.blocklyDropDownDiv.blocklyFocused {
|
||||
.blocklyDropDownDiv:focus {
|
||||
box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
.blocklyDropDownContent {
|
||||
max-height: 300px; /* @todo: spec for maximum height. */
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blocklyDropDownArrow {
|
||||
@@ -141,47 +135,14 @@ let content = `
|
||||
z-index: -1;
|
||||
background-color: inherit;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
.blocklyDropDownButton {
|
||||
display: inline-block;
|
||||
float: left;
|
||||
padding: 0;
|
||||
margin: 4px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
border: 1px solid;
|
||||
transition: box-shadow .1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.blocklyArrowTop {
|
||||
border-top: 1px solid;
|
||||
border-left: 1px solid;
|
||||
border-top-left-radius: 4px;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
.blocklyArrowBottom {
|
||||
border-bottom: 1px solid;
|
||||
border-right: 1px solid;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
.blocklyResizeSE {
|
||||
cursor: se-resize;
|
||||
fill: #aaa;
|
||||
}
|
||||
|
||||
.blocklyResizeSW {
|
||||
cursor: sw-resize;
|
||||
fill: #aaa;
|
||||
}
|
||||
|
||||
.blocklyResizeLine {
|
||||
stroke: #515A5A;
|
||||
stroke-width: 1;
|
||||
.blocklyHighlighted>.blocklyPath {
|
||||
filter: var(--blocklyEmbossFilter);
|
||||
}
|
||||
|
||||
.blocklyHighlightedConnectionPath {
|
||||
@@ -234,7 +195,8 @@ let content = `
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blocklyDisabled>.blocklyPath {
|
||||
.blocklyDisabledPattern>.blocklyPath {
|
||||
fill: var(--blocklyDisabledPattern);
|
||||
fill-opacity: .5;
|
||||
stroke-opacity: .5;
|
||||
}
|
||||
@@ -251,7 +213,7 @@ let content = `
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.blocklyNonEditableText>text {
|
||||
.blocklyNonEditableField>text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -264,12 +226,15 @@ let content = `
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.blocklyHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blocklyFieldDropdown:not(.blocklyHidden) {
|
||||
display: block;
|
||||
/*
|
||||
Don't allow users to select text. It gets annoying when trying to
|
||||
drag a block and selected text moves instead.
|
||||
*/
|
||||
.blocklySvg text {
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.blocklyIconGroup {
|
||||
@@ -419,6 +384,9 @@ input[type=number] {
|
||||
}
|
||||
|
||||
.blocklyWidgetDiv .blocklyMenu {
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
|
||||
@@ -433,16 +401,21 @@ input[type=number] {
|
||||
z-index: 20000; /* Arbitrary, but some apps depend on it... */
|
||||
}
|
||||
|
||||
.blocklyWidgetDiv .blocklyMenu.blocklyFocused {
|
||||
.blocklyWidgetDiv .blocklyMenu:focus {
|
||||
box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
.blocklyDropDownDiv .blocklyMenu {
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: inherit; /* Compatibility with gapi, reset from goog-menu */
|
||||
border: inherit; /* Compatibility with gapi, reset from goog-menu */
|
||||
font: normal 13px "Helvetica Neue", Helvetica, sans-serif;
|
||||
outline: none;
|
||||
position: relative; /* Compatibility with gapi, reset from goog-menu */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 100%;
|
||||
z-index: 20000; /* Arbitrary, but some apps depend on it... */
|
||||
}
|
||||
|
||||
@@ -489,6 +462,14 @@ input[type=number] {
|
||||
margin-right: -24px;
|
||||
}
|
||||
|
||||
.blocklyMenuSeparator {
|
||||
background-color: #ccc;
|
||||
height: 1px;
|
||||
border: 0;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.blocklyBlockDragSurface, .blocklyAnimationLayer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -499,4 +480,31 @@ input[type=number] {
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.blocklyField {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.blocklyInputField {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.blocklyDragging .blocklyField,
|
||||
.blocklyDragging .blocklyIconGroup {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.blocklyActiveFocus:is(
|
||||
.blocklyFlyout,
|
||||
.blocklyWorkspace,
|
||||
.blocklyField,
|
||||
.blocklyPath,
|
||||
.blocklyHighlightedConnectionPath,
|
||||
.blocklyComment,
|
||||
.blocklyBubble,
|
||||
.blocklyIconGroup,
|
||||
.blocklyTextarea
|
||||
) {
|
||||
outline-width: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
100
core/dialog.ts
100
core/dialog.ts
@@ -6,31 +6,43 @@
|
||||
|
||||
// Former goog.module ID: Blockly.dialog
|
||||
|
||||
let alertImplementation = function (
|
||||
message: string,
|
||||
opt_callback?: () => void,
|
||||
) {
|
||||
import type {ToastOptions} from './toast.js';
|
||||
import {Toast} from './toast.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
const defaultAlert = function (message: string, opt_callback?: () => void) {
|
||||
window.alert(message);
|
||||
if (opt_callback) {
|
||||
opt_callback();
|
||||
}
|
||||
};
|
||||
|
||||
let confirmImplementation = function (
|
||||
let alertImplementation = defaultAlert;
|
||||
|
||||
const defaultConfirm = function (
|
||||
message: string,
|
||||
callback: (result: boolean) => void,
|
||||
) {
|
||||
callback(window.confirm(message));
|
||||
};
|
||||
|
||||
let promptImplementation = function (
|
||||
let confirmImplementation = defaultConfirm;
|
||||
|
||||
const defaultPrompt = function (
|
||||
message: string,
|
||||
defaultValue: string,
|
||||
callback: (result: string | null) => void,
|
||||
) {
|
||||
// NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native
|
||||
// window prompt since it prevents focus from changing while open.
|
||||
callback(window.prompt(message, defaultValue));
|
||||
};
|
||||
|
||||
let promptImplementation = defaultPrompt;
|
||||
|
||||
const defaultToast = Toast.show.bind(Toast);
|
||||
let toastImplementation = defaultToast;
|
||||
|
||||
/**
|
||||
* Wrapper to window.alert() that app developers may override via setAlert to
|
||||
* provide alternatives to the modal browser window.
|
||||
@@ -45,10 +57,16 @@ export function alert(message: string, opt_callback?: () => void) {
|
||||
/**
|
||||
* Sets the function to be run when Blockly.dialog.alert() is called.
|
||||
*
|
||||
* @param alertFunction The function to be run.
|
||||
* @param alertFunction The function to be run, or undefined to restore the
|
||||
* default implementation.
|
||||
* @see Blockly.dialog.alert
|
||||
*/
|
||||
export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {
|
||||
export function setAlert(
|
||||
alertFunction: (
|
||||
message: string,
|
||||
callback?: () => void,
|
||||
) => void = defaultAlert,
|
||||
) {
|
||||
alertImplementation = alertFunction;
|
||||
}
|
||||
|
||||
@@ -59,25 +77,22 @@ export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {
|
||||
* @param message The message to display to the user.
|
||||
* @param callback The callback for handling user response.
|
||||
*/
|
||||
export function confirm(message: string, callback: (p1: boolean) => void) {
|
||||
TEST_ONLY.confirmInternal(message, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Private version of confirm for stubbing in tests.
|
||||
*/
|
||||
function confirmInternal(message: string, callback: (p1: boolean) => void) {
|
||||
export function confirm(message: string, callback: (result: boolean) => void) {
|
||||
confirmImplementation(message, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the function to be run when Blockly.dialog.confirm() is called.
|
||||
*
|
||||
* @param confirmFunction The function to be run.
|
||||
* @param confirmFunction The function to be run, or undefined to restore the
|
||||
* default implementation.
|
||||
* @see Blockly.dialog.confirm
|
||||
*/
|
||||
export function setConfirm(
|
||||
confirmFunction: (p1: string, p2: (p1: boolean) => void) => void,
|
||||
confirmFunction: (
|
||||
message: string,
|
||||
callback: (result: boolean) => void,
|
||||
) => void = defaultConfirm,
|
||||
) {
|
||||
confirmImplementation = confirmFunction;
|
||||
}
|
||||
@@ -95,7 +110,7 @@ export function setConfirm(
|
||||
export function prompt(
|
||||
message: string,
|
||||
defaultValue: string,
|
||||
callback: (p1: string | null) => void,
|
||||
callback: (result: string | null) => void,
|
||||
) {
|
||||
promptImplementation(message, defaultValue, callback);
|
||||
}
|
||||
@@ -103,19 +118,50 @@ export function prompt(
|
||||
/**
|
||||
* Sets the function to be run when Blockly.dialog.prompt() is called.
|
||||
*
|
||||
* @param promptFunction The function to be run.
|
||||
* **Important**: When overridding this, be aware that non-native prompt
|
||||
* experiences may require managing ephemeral focus in FocusManager. This isn't
|
||||
* needed for the native window prompt because it prevents focus from being
|
||||
* changed while open.
|
||||
*
|
||||
* @param promptFunction The function to be run, or undefined to restore the
|
||||
* default implementation.
|
||||
* @see Blockly.dialog.prompt
|
||||
*/
|
||||
export function setPrompt(
|
||||
promptFunction: (
|
||||
p1: string,
|
||||
p2: string,
|
||||
p3: (p1: string | null) => void,
|
||||
) => void,
|
||||
message: string,
|
||||
defaultValue: string,
|
||||
callback: (result: string | null) => void,
|
||||
) => void = defaultPrompt,
|
||||
) {
|
||||
promptImplementation = promptFunction;
|
||||
}
|
||||
|
||||
export const TEST_ONLY = {
|
||||
confirmInternal,
|
||||
};
|
||||
/**
|
||||
* Displays a temporary notification atop the workspace. Blockly provides a
|
||||
* default toast implementation, but developers may provide their own via
|
||||
* setToast. For simple appearance customization, CSS should be sufficient.
|
||||
*
|
||||
* @param workspace The workspace to display the toast notification atop.
|
||||
* @param options Configuration options for the notification, including its
|
||||
* message and duration.
|
||||
*/
|
||||
export function toast(workspace: WorkspaceSvg, options: ToastOptions) {
|
||||
toastImplementation(workspace, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the function to be run when Blockly.dialog.toast() is called.
|
||||
*
|
||||
* @param toastFunction The function to be run, or undefined to restore the
|
||||
* default implementation.
|
||||
* @see Blockly.dialog.toast
|
||||
*/
|
||||
export function setToast(
|
||||
toastFunction: (
|
||||
workspace: WorkspaceSvg,
|
||||
options: ToastOptions,
|
||||
) => void = defaultToast,
|
||||
) {
|
||||
toastImplementation = toastFunction;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
122
core/events/events_var_type_change.ts
Normal file
122
core/events/events_var_type_change.ts
Normal 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,
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ` +
|
||||
|
||||
177
core/field.ts
177
core/field.ts
@@ -23,17 +23,17 @@ import * as dropDownDiv from './dropdowndiv.js';
|
||||
import {EventType} from './events/type.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import type {Input} from './inputs/input.js';
|
||||
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
|
||||
import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js';
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
|
||||
import type {IRegistrable} from './interfaces/i_registrable.js';
|
||||
import {ISerializable} from './interfaces/i_serializable.js';
|
||||
import {MarkerManager} from './marker_manager.js';
|
||||
import type {ConstantProvider} from './renderers/common/constants.js';
|
||||
import type {KeyboardShortcut} from './shortcut_registry.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import type {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
import {Size} from './utils/size.js';
|
||||
@@ -42,7 +42,7 @@ import {Svg} from './utils/svg.js';
|
||||
import * as userAgent from './utils/useragent.js';
|
||||
import * as utilsXml from './utils/xml.js';
|
||||
import * as WidgetDiv from './widgetdiv.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
import {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
/**
|
||||
* A function that is called to validate changes to the field's value before
|
||||
@@ -67,12 +67,7 @@ export type FieldValidator<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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
33
core/flyout_item.ts
Normal 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
24
core/flyout_navigator.ts
Normal 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
94
core/flyout_separator.ts
Normal 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',
|
||||
}
|
||||
@@ -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
520
core/focus_manager.ts
Normal 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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
core/grid.ts
15
core/grid.ts
@@ -210,6 +210,9 @@ export class Grid {
|
||||
* @param rnd A random ID to append to the pattern's ID.
|
||||
* @param gridOptions The object containing grid configuration.
|
||||
* @param defs The root SVG element for this workspace's defs.
|
||||
* @param injectionDiv The div containing the parent workspace and all related
|
||||
* workspaces and block containers. CSS variables representing SVG patterns
|
||||
* will be scoped to this container.
|
||||
* @returns The SVG element for the grid pattern.
|
||||
* @internal
|
||||
*/
|
||||
@@ -217,6 +220,7 @@ export class Grid {
|
||||
rnd: string,
|
||||
gridOptions: GridOptions,
|
||||
defs: SVGElement,
|
||||
injectionDiv?: HTMLElement,
|
||||
): SVGElement {
|
||||
/*
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
51
core/interfaces/i_flyout_inflater.ts
Normal file
51
core/interfaces/i_flyout_inflater.ts
Normal 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;
|
||||
}
|
||||
115
core/interfaces/i_focusable_node.ts
Normal file
115
core/interfaces/i_focusable_node.ts
Normal 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
|
||||
);
|
||||
}
|
||||
144
core/interfaces/i_focusable_tree.ts
Normal file
144
core/interfaces/i_focusable_tree.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
69
core/interfaces/i_navigation_policy.ts
Normal file
69
core/interfaces/i_navigation_policy.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
65
core/interfaces/i_variable_map.ts
Normal file
65
core/interfaces/i_variable_map.ts
Normal 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;
|
||||
}
|
||||
57
core/interfaces/i_variable_model.ts
Normal file
57
core/interfaces/i_variable_model.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
165
core/keyboard_nav/block_navigation_policy.ts
Normal file
165
core/keyboard_nav/block_navigation_policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
189
core/keyboard_nav/connection_navigation_policy.ts
Normal file
189
core/keyboard_nav/connection_navigation_policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
118
core/keyboard_nav/field_navigation_policy.ts
Normal file
118
core/keyboard_nav/field_navigation_policy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
core/keyboard_nav/flyout_button_navigation_policy.ts
Normal file
76
core/keyboard_nav/flyout_button_navigation_policy.ts
Normal 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
Reference in New Issue
Block a user