Merge branch 'rc/v12.0.0' into develop-v12-merge

This commit is contained in:
Aaron Dodson
2025-03-11 09:42:25 -07:00
committed by GitHub
148 changed files with 3869 additions and 3295 deletions

View File

@@ -352,6 +352,11 @@
// Needs investigation. // Needs investigation.
"ae-forgotten-export": { "ae-forgotten-export": {
"logLevel": "none" "logLevel": "none"
},
// We don't prefix our internal APIs with underscores.
"ae-internal-missing-underscore": {
"logLevel": "none"
} }
}, },

View File

@@ -269,7 +269,7 @@ const CUSTOM_CONTEXT_MENU_CREATE_VARIABLES_GET_MIXIN = {
} }
const varField = this.getField('VAR') as FieldVariable; const varField = this.getField('VAR') as FieldVariable;
const variable = varField.getVariable()!; const variable = varField.getVariable()!;
const varName = variable.name; const varName = variable.getName();
if (!this.isCollapsed() && varName !== null) { if (!this.isCollapsed() && varName !== null) {
const getVarBlockState = { const getVarBlockState = {
type: 'variables_get', type: 'variables_get',

View File

@@ -31,11 +31,14 @@ import '../core/icons/comment_icon.js';
import {MutatorIcon as Mutator} from '../core/icons/mutator_icon.js'; import {MutatorIcon as Mutator} from '../core/icons/mutator_icon.js';
import '../core/icons/warning_icon.js'; import '../core/icons/warning_icon.js';
import {Align} from '../core/inputs/align.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 {Msg} from '../core/msg.js';
import {Names} from '../core/names.js'; import {Names} from '../core/names.js';
import * as Procedures from '../core/procedures.js'; import * as Procedures from '../core/procedures.js';
import * as xmlUtils from '../core/utils/xml.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 * as Variables from '../core/variables.js';
import type {Workspace} from '../core/workspace.js'; import type {Workspace} from '../core/workspace.js';
import type {WorkspaceSvg} from '../core/workspace_svg.js'; import type {WorkspaceSvg} from '../core/workspace_svg.js';
@@ -48,7 +51,7 @@ export const blocks: {[key: string]: BlockDefinition} = {};
type ProcedureBlock = Block & ProcedureMixin; type ProcedureBlock = Block & ProcedureMixin;
interface ProcedureMixin extends ProcedureMixinType { interface ProcedureMixin extends ProcedureMixinType {
arguments_: string[]; arguments_: string[];
argumentVarModels_: VariableModel[]; argumentVarModels_: IVariableModel<IVariableState>[];
callType_: string; callType_: string;
paramIds_: string[]; paramIds_: string[];
hasStatements_: boolean; hasStatements_: boolean;
@@ -128,7 +131,7 @@ const PROCEDURE_DEF_COMMON = {
for (let i = 0; i < this.argumentVarModels_.length; i++) { for (let i = 0; i < this.argumentVarModels_.length; i++) {
const parameter = xmlUtils.createElement('arg'); const parameter = xmlUtils.createElement('arg');
const argModel = this.argumentVarModels_[i]; const argModel = this.argumentVarModels_[i];
parameter.setAttribute('name', argModel.name); parameter.setAttribute('name', argModel.getName());
parameter.setAttribute('varid', argModel.getId()); parameter.setAttribute('varid', argModel.getId());
if (opt_paramIds && this.paramIds_) { if (opt_paramIds && this.paramIds_) {
parameter.setAttribute('paramId', this.paramIds_[i]); parameter.setAttribute('paramId', this.paramIds_[i]);
@@ -196,7 +199,7 @@ const PROCEDURE_DEF_COMMON = {
state['params'].push({ state['params'].push({
// We don't need to serialize the name, but just in case we decide // We don't need to serialize the name, but just in case we decide
// to separate params from variables. // to separate params from variables.
'name': this.argumentVarModels_[i].name, 'name': this.argumentVarModels_[i].getName(),
'id': this.argumentVarModels_[i].getId(), 'id': this.argumentVarModels_[i].getId(),
}); });
} }
@@ -224,7 +227,7 @@ const PROCEDURE_DEF_COMMON = {
param['name'], param['name'],
'', '',
); );
this.arguments_.push(variable.name); this.arguments_.push(variable.getName());
this.argumentVarModels_.push(variable); this.argumentVarModels_.push(variable);
} }
} }
@@ -352,7 +355,9 @@ const PROCEDURE_DEF_COMMON = {
* *
* @returns List of variable models. * @returns List of variable models.
*/ */
getVarModels: function (this: ProcedureBlock): VariableModel[] { getVarModels: function (
this: ProcedureBlock,
): IVariableModel<IVariableState>[] {
return this.argumentVarModels_; return this.argumentVarModels_;
}, },
/** /**
@@ -370,23 +375,23 @@ const PROCEDURE_DEF_COMMON = {
newId: string, newId: string,
) { ) {
const oldVariable = this.workspace.getVariableById(oldId)!; const oldVariable = this.workspace.getVariableById(oldId)!;
if (oldVariable.type !== '') { if (oldVariable.getType() !== '') {
// Procedure arguments always have the empty type. // Procedure arguments always have the empty type.
return; return;
} }
const oldName = oldVariable.name; const oldName = oldVariable.getName();
const newVar = this.workspace.getVariableById(newId)!; const newVar = this.workspace.getVariableById(newId)!;
let change = false; let change = false;
for (let i = 0; i < this.argumentVarModels_.length; i++) { for (let i = 0; i < this.argumentVarModels_.length; i++) {
if (this.argumentVarModels_[i].getId() === oldId) { if (this.argumentVarModels_[i].getId() === oldId) {
this.arguments_[i] = newVar.name; this.arguments_[i] = newVar.getName();
this.argumentVarModels_[i] = newVar; this.argumentVarModels_[i] = newVar;
change = true; change = true;
} }
} }
if (change) { if (change) {
this.displayRenamedVar_(oldName, newVar.name); this.displayRenamedVar_(oldName, newVar.getName());
Procedures.mutateCallers(this); Procedures.mutateCallers(this);
} }
}, },
@@ -398,9 +403,9 @@ const PROCEDURE_DEF_COMMON = {
*/ */
updateVarName: function ( updateVarName: function (
this: ProcedureBlock & BlockSvg, this: ProcedureBlock & BlockSvg,
variable: VariableModel, variable: IVariableModel<IVariableState>,
) { ) {
const newName = variable.name; const newName = variable.getName();
let change = false; let change = false;
let oldName; let oldName;
for (let i = 0; i < this.argumentVarModels_.length; i++) { for (let i = 0; i < this.argumentVarModels_.length; i++) {
@@ -473,12 +478,16 @@ const PROCEDURE_DEF_COMMON = {
const getVarBlockState = { const getVarBlockState = {
type: 'variables_get', type: 'variables_get',
fields: { fields: {
VAR: {name: argVar.name, id: argVar.getId(), type: argVar.type}, VAR: {
name: argVar.getName(),
id: argVar.getId(),
type: argVar.getType(),
},
}, },
}; };
options.push({ options.push({
enabled: true, 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), callback: ContextMenu.callbackFactory(this, getVarBlockState),
}); });
} }
@@ -620,30 +629,49 @@ type ArgumentBlock = Block & ArgumentMixin;
interface ArgumentMixin extends ArgumentMixinType {} interface ArgumentMixin extends ArgumentMixinType {}
type ArgumentMixinType = typeof PROCEDURES_MUTATORARGUMENT; type ArgumentMixinType = typeof PROCEDURES_MUTATORARGUMENT;
// TODO(#6920): This is kludgy. /**
type FieldTextInputForArgument = FieldTextInput & { * Field responsible for editing procedure argument names.
oldShowEditorFn_(_e?: Event, quietInput?: boolean): void; */
createdVariables_: VariableModel[]; 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 = { const PROCEDURES_MUTATORARGUMENT = {
/** /**
* Mutator block for procedure argument. * Mutator block for procedure argument.
*/ */
init: function (this: ArgumentBlock) { init: function (this: ArgumentBlock) {
const field = fieldRegistry.fromJson({ const field = new ProcedureArgumentField(
type: 'field_input', Procedures.DEFAULT_ARG,
text: Procedures.DEFAULT_ARG, this.validator_,
}) 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;
this.appendDummyInput() this.appendDummyInput()
.appendField(Msg['PROCEDURES_MUTATORARG_TITLE']) .appendField(Msg['PROCEDURES_MUTATORARG_TITLE'])
@@ -653,14 +681,6 @@ const PROCEDURES_MUTATORARGUMENT = {
this.setStyle('procedure_blocks'); this.setStyle('procedure_blocks');
this.setTooltip(Msg['PROCEDURES_MUTATORARG_TOOLTIP']); this.setTooltip(Msg['PROCEDURES_MUTATORARG_TOOLTIP']);
this.contextMenu = false; 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. * @returns Valid name, or null if a name was not specified.
*/ */
validator_: function ( validator_: function (
this: FieldTextInputForArgument, this: ProcedureArgumentField,
varName: string, varName: string,
): string | null { ): string | null {
const sourceBlock = this.getSourceBlock()!; const sourceBlock = this.getSourceBlock()!;
const outerWs = sourceBlock!.workspace.getRootWorkspace()!; const outerWs = sourceBlock.workspace.getRootWorkspace()!;
varName = varName.replace(/[\s\xa0]+/g, ' ').replace(/^ | $/g, ''); varName = varName.replace(/[\s\xa0]+/g, ' ').replace(/^ | $/g, '');
if (!varName) { if (!varName) {
return null; return null;
@@ -707,50 +727,31 @@ const PROCEDURES_MUTATORARGUMENT = {
return varName; return varName;
} }
let model = outerWs.getVariable(varName, ''); const model = outerWs.getVariable(varName, '');
if (model && model.name !== varName) { if (model && model.getName() !== varName) {
// Rename the variable (case change) // Rename the variable (case change)
outerWs.renameVariableById(model.getId(), varName); outerWs.renameVariableById(model.getId(), varName);
} }
if (!model) { if (!model) {
model = outerWs.createVariable(varName, ''); if (this.editingInteractively) {
if (model && this.createdVariables_) { if (!this.editingVariable) {
this.createdVariables_.push(model); this.editingVariable = outerWs.createVariable(varName, '');
} else {
outerWs.renameVariableById(this.editingVariable.getId(), varName);
}
} else {
outerWs.createVariable(varName, '');
} }
} }
return 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; blocks['procedures_mutatorarg'] = PROCEDURES_MUTATORARGUMENT;
/** Type of a block using the PROCEDURE_CALL_COMMON mixin. */ /** Type of a block using the PROCEDURE_CALL_COMMON mixin. */
type CallBlock = Block & CallMixin; type CallBlock = Block & CallMixin;
interface CallMixin extends CallMixinType { interface CallMixin extends CallMixinType {
argumentVarModels_: VariableModel[]; argumentVarModels_: IVariableModel<IVariableState>[];
arguments_: string[]; arguments_: string[];
defType_: string; defType_: string;
quarkIds_: string[] | null; quarkIds_: string[] | null;
@@ -1029,7 +1030,7 @@ const PROCEDURE_CALL_COMMON = {
* *
* @returns List of variable models. * @returns List of variable models.
*/ */
getVarModels: function (this: CallBlock): VariableModel[] { getVarModels: function (this: CallBlock): IVariableModel<IVariableState>[] {
return this.argumentVarModels_; return this.argumentVarModels_;
}, },
/** /**

View File

@@ -165,11 +165,12 @@ const deleteOptionCallbackFactory = function (
block: VariableBlock, block: VariableBlock,
): () => void { ): () => void {
return function () { return function () {
const workspace = block.workspace;
const variableField = block.getField('VAR') as FieldVariable; const variableField = block.getField('VAR') as FieldVariable;
const variable = variableField.getVariable()!; const variable = variableField.getVariable();
workspace.deleteVariableById(variable.getId()); if (variable) {
(workspace as WorkspaceSvg).refreshToolboxSelection(); Variables.deleteVariable(variable.getWorkspace(), variable, block);
}
(block.workspace as WorkspaceSvg).refreshToolboxSelection();
}; };
}; };

View File

@@ -144,9 +144,9 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
const id = this.getFieldValue('VAR'); const id = this.getFieldValue('VAR');
const variableModel = Variables.getVariable(this.workspace, id)!; const variableModel = Variables.getVariable(this.workspace, id)!;
if (this.type === 'variables_get_dynamic') { if (this.type === 'variables_get_dynamic') {
this.outputConnection!.setCheck(variableModel.type); this.outputConnection!.setCheck(variableModel.getType());
} else { } else {
this.getInput('VALUE')!.connection!.setCheck(variableModel.type); this.getInput('VALUE')!.connection!.setCheck(variableModel.getType());
} }
}, },
}; };
@@ -176,11 +176,12 @@ const renameOptionCallbackFactory = function (block: VariableBlock) {
*/ */
const deleteOptionCallbackFactory = function (block: VariableBlock) { const deleteOptionCallbackFactory = function (block: VariableBlock) {
return function () { return function () {
const workspace = block.workspace;
const variableField = block.getField('VAR') as FieldVariable; const variableField = block.getField('VAR') as FieldVariable;
const variable = variableField.getVariable()!; const variable = variableField.getVariable();
workspace.deleteVariableById(variable.getId()); if (variable) {
(workspace as WorkspaceSvg).refreshToolboxSelection(); Variables.deleteVariable(variable.getWorkspace(), variable, block);
}
(block.workspace as WorkspaceSvg).refreshToolboxSelection();
}; };
}; };

View File

@@ -43,6 +43,10 @@ import {ValueInput} from './inputs/value_input.js';
import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js'; import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
import {isCommentIcon} from './interfaces/i_comment_icon.js'; import {isCommentIcon} from './interfaces/i_comment_icon.js';
import {type IIcon} from './interfaces/i_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 registry from './registry.js';
import * as Tooltip from './tooltip.js'; import * as Tooltip from './tooltip.js';
import * as arrayUtils from './utils/array.js'; import * as arrayUtils from './utils/array.js';
@@ -51,7 +55,6 @@ import * as deprecation from './utils/deprecation.js';
import * as idGenerator from './utils/idgenerator.js'; import * as idGenerator from './utils/idgenerator.js';
import * as parsing from './utils/parsing.js'; import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js'; import {Size} from './utils/size.js';
import type {VariableModel} from './variable_model.js';
import type {Workspace} from './workspace.js'; import type {Workspace} from './workspace.js';
/** /**
@@ -792,7 +795,7 @@ export class Block implements IASTNodeLocation {
this.deletable && this.deletable &&
!this.shadow && !this.shadow &&
!this.isDeadOrDying() && !this.isDeadOrDying() &&
!this.workspace.options.readOnly !this.workspace.isReadOnly()
); );
} }
@@ -825,7 +828,7 @@ export class Block implements IASTNodeLocation {
this.movable && this.movable &&
!this.shadow && !this.shadow &&
!this.isDeadOrDying() && !this.isDeadOrDying() &&
!this.workspace.options.readOnly !this.workspace.isReadOnly()
); );
} }
@@ -914,7 +917,7 @@ export class Block implements IASTNodeLocation {
*/ */
isEditable(): boolean { isEditable(): boolean {
return ( return (
this.editable && !this.isDeadOrDying() && !this.workspace.options.readOnly this.editable && !this.isDeadOrDying() && !this.workspace.isReadOnly()
); );
} }
@@ -934,10 +937,8 @@ export class Block implements IASTNodeLocation {
*/ */
setEditable(editable: boolean) { setEditable(editable: boolean) {
this.editable = editable; this.editable = editable;
for (let i = 0, input; (input = this.inputList[i]); i++) { for (const field of this.getFields()) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) { field.updateEditable();
field.updateEditable();
}
} }
} }
@@ -1104,16 +1105,27 @@ export class Block implements IASTNodeLocation {
' instead', ' instead',
); );
} }
for (let i = 0, input; (input = this.inputList[i]); i++) { for (const field of this.getFields()) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) { if (field.name === name) {
if (field.name === name) { return field;
return field;
}
} }
} }
return null; 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. * Return all variables referenced by this block.
* *
@@ -1121,12 +1133,9 @@ export class Block implements IASTNodeLocation {
*/ */
getVars(): string[] { getVars(): string[] {
const vars: string[] = []; const vars: string[] = [];
for (let i = 0, input; (input = this.inputList[i]); i++) { for (const field of this.getFields()) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) { if (field.referencesVariables()) {
if (field.referencesVariables()) { vars.push(field.getValue());
// NOTE: This only applies to `FieldVariable`, a `Field<string>`
vars.push(field.getValue() as string);
}
} }
} }
return vars; return vars;
@@ -1138,19 +1147,17 @@ export class Block implements IASTNodeLocation {
* @returns List of variable models. * @returns List of variable models.
* @internal * @internal
*/ */
getVarModels(): VariableModel[] { getVarModels(): IVariableModel<IVariableState>[] {
const vars = []; const vars = [];
for (let i = 0, input; (input = this.inputList[i]); i++) { for (const field of this.getFields()) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) { if (field.referencesVariables()) {
if (field.referencesVariables()) { const model = this.workspace.getVariableById(
const model = this.workspace.getVariableById( field.getValue() as string,
field.getValue() as string, );
); // Check if the variable actually exists (and isn't just a potential
// Check if the variable actually exists (and isn't just a potential // variable).
// variable). if (model) {
if (model) { vars.push(model);
vars.push(model);
}
} }
} }
} }
@@ -1164,15 +1171,13 @@ export class Block implements IASTNodeLocation {
* @param variable The variable being renamed. * @param variable The variable being renamed.
* @internal * @internal
*/ */
updateVarName(variable: VariableModel) { updateVarName(variable: IVariableModel<IVariableState>) {
for (let i = 0, input; (input = this.inputList[i]); i++) { for (const field of this.getFields()) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) { if (
if ( field.referencesVariables() &&
field.referencesVariables() && variable.getId() === field.getValue()
variable.getId() === field.getValue() ) {
) { field.refreshVariableName();
field.refreshVariableName();
}
} }
} }
} }
@@ -1186,11 +1191,9 @@ export class Block implements IASTNodeLocation {
* updated name. * updated name.
*/ */
renameVarById(oldId: string, newId: string) { renameVarById(oldId: string, newId: string) {
for (let i = 0, input; (input = this.inputList[i]); i++) { for (const field of this.getFields()) {
for (let j = 0, field; (field = input.fieldRow[j]); j++) { if (field.referencesVariables() && oldId === field.getValue()) {
if (field.referencesVariables() && oldId === field.getValue()) { field.setValue(newId);
field.setValue(newId);
}
} }
} }
} }
@@ -1408,7 +1411,7 @@ export class Block implements IASTNodeLocation {
return this.disabledReasons.size === 0; return this.disabledReasons.size === 0;
} }
/** @deprecated v11 - Get whether the block is manually disabled. */ /** @deprecated v11 - Get or sets whether the block is manually disabled. */
private get disabled(): boolean { private get disabled(): boolean {
deprecation.warn( deprecation.warn(
'disabled', 'disabled',
@@ -1419,7 +1422,6 @@ export class Block implements IASTNodeLocation {
return this.hasDisabledReason(constants.MANUALLY_DISABLED); return this.hasDisabledReason(constants.MANUALLY_DISABLED);
} }
/** @deprecated v11 - Set whether the block is manually disabled. */
private set disabled(value: boolean) { private set disabled(value: boolean) {
deprecation.warn( deprecation.warn(
'disabled', 'disabled',
@@ -2516,7 +2518,7 @@ export class Block implements IASTNodeLocation {
* *
* Intended to on be used in console logs and errors. If you need a string * 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, * 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. * @returns The description.
*/ */

View File

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

View File

@@ -194,6 +194,9 @@ export class BlockSvg
this.workspace = workspace; this.workspace = workspace;
this.svgGroup = dom.createSvgElement(Svg.G, {}); this.svgGroup = dom.createSvgElement(Svg.G, {});
if (prototypeName) {
dom.addClass(this.svgGroup, prototypeName);
}
/** A block style object. */ /** A block style object. */
this.style = workspace.getRenderer().getConstants().getBlockStyle(null); this.style = workspace.getRenderer().getConstants().getBlockStyle(null);
@@ -228,7 +231,7 @@ export class BlockSvg
this.applyColour(); this.applyColour();
this.pathObject.updateMovable(this.isMovable() || this.isInFlyout); this.pathObject.updateMovable(this.isMovable() || this.isInFlyout);
const svg = this.getSvgRoot(); const svg = this.getSvgRoot();
if (!this.workspace.options.readOnly && svg) { if (svg) {
browserEvents.conditionalBind(svg, 'pointerdown', this, this.onMouseDown); browserEvents.conditionalBind(svg, 'pointerdown', this, this.onMouseDown);
} }
@@ -529,9 +532,12 @@ export class BlockSvg
if (!collapsed) { if (!collapsed) {
this.updateDisabled(); this.updateDisabled();
this.removeInput(collapsedInputName); this.removeInput(collapsedInputName);
dom.removeClass(this.svgGroup, 'blocklyCollapsed');
return; return;
} }
dom.addClass(this.svgGroup, 'blocklyCollapsed');
const text = this.toString(internalConstants.COLLAPSE_CHARS); const text = this.toString(internalConstants.COLLAPSE_CHARS);
const field = this.getField(collapsedFieldName); const field = this.getField(collapsedFieldName);
if (field) { if (field) {
@@ -579,6 +585,8 @@ export class BlockSvg
* @param e Pointer down event. * @param e Pointer down event.
*/ */
private onMouseDown(e: PointerEvent) { private onMouseDown(e: PointerEvent) {
if (this.workspace.isReadOnly()) return;
const gesture = this.workspace.getGesture(e); const gesture = this.workspace.getGesture(e);
if (gesture) { if (gesture) {
gesture.handleBlockStart(e, this); gesture.handleBlockStart(e, this);
@@ -606,7 +614,7 @@ export class BlockSvg
protected generateContextMenu(): Array< protected generateContextMenu(): Array<
ContextMenuOption | LegacyContextMenuOption ContextMenuOption | LegacyContextMenuOption
> | null { > | null {
if (this.workspace.options.readOnly || !this.contextMenu) { if (this.workspace.isReadOnly() || !this.contextMenu) {
return null; return null;
} }
const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions( const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
@@ -676,6 +684,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 * Recursively adds or removes the dragging class to this node and its
* children. * children.
@@ -688,10 +714,10 @@ export class BlockSvg
if (adding) { if (adding) {
this.translation = ''; this.translation = '';
common.draggingConnections.push(...this.getConnections_(true)); common.draggingConnections.push(...this.getConnections_(true));
dom.addClass(this.svgGroup, 'blocklyDragging'); this.addClass('blocklyDragging');
} else { } else {
common.draggingConnections.length = 0; common.draggingConnections.length = 0;
dom.removeClass(this.svgGroup, 'blocklyDragging'); this.removeClass('blocklyDragging');
} }
// Recurse through all blocks attached under this one. // Recurse through all blocks attached under this one.
for (let i = 0; i < this.childBlocks_.length; i++) { for (let i = 0; i < this.childBlocks_.length; i++) {
@@ -716,6 +742,13 @@ export class BlockSvg
*/ */
override setEditable(editable: boolean) { override setEditable(editable: boolean) {
super.setEditable(editable); super.setEditable(editable);
if (editable) {
dom.removeClass(this.svgGroup, 'blocklyNotEditable');
} else {
dom.addClass(this.svgGroup, 'blocklyNotEditable');
}
const icons = this.getIcons(); const icons = this.getIcons();
for (let i = 0; i < icons.length; i++) { for (let i = 0; i < icons.length; i++) {
icons[i].updateEditable(); icons[i].updateEditable();
@@ -873,17 +906,15 @@ export class BlockSvg
* @internal * @internal
*/ */
applyColour() { applyColour() {
this.pathObject.applyColour(this); this.pathObject.applyColour?.(this);
const icons = this.getIcons(); const icons = this.getIcons();
for (let i = 0; i < icons.length; i++) { for (let i = 0; i < icons.length; i++) {
icons[i].applyColour(); icons[i].applyColour();
} }
for (let x = 0, input; (input = this.inputList[x]); x++) { for (const field of this.getFields()) {
for (let y = 0, field; (field = input.fieldRow[y]); y++) { field.applyColour();
field.applyColour();
}
} }
} }
@@ -1075,6 +1106,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 * Set whether the block is highlighted or not. Block highlighting is
* often used to visually mark blocks currently being executed. * often used to visually mark blocks currently being executed.
@@ -1139,7 +1184,7 @@ export class BlockSvg
.getConstants() .getConstants()
.getBlockStyleForColour(this.colour_); .getBlockStyleForColour(this.colour_);
this.pathObject.setStyle(styleObj.style); this.pathObject.setStyle?.(styleObj.style);
this.style = styleObj.style; this.style = styleObj.style;
this.styleName_ = styleObj.name; this.styleName_ = styleObj.name;
@@ -1157,16 +1202,22 @@ export class BlockSvg
.getRenderer() .getRenderer()
.getConstants() .getConstants()
.getBlockStyle(blockStyleName); .getBlockStyle(blockStyleName);
this.styleName_ = blockStyleName;
if (this.styleName_) {
dom.removeClass(this.svgGroup, this.styleName_);
}
if (blockStyle) { if (blockStyle) {
this.hat = blockStyle.hat; this.hat = blockStyle.hat;
this.pathObject.setStyle(blockStyle); this.pathObject.setStyle?.(blockStyle);
// Set colour to match Block. // Set colour to match Block.
this.colour_ = blockStyle.colourPrimary; this.colour_ = blockStyle.colourPrimary;
this.style = blockStyle; this.style = blockStyle;
this.applyColour(); this.applyColour();
dom.addClass(this.svgGroup, blockStyleName);
this.styleName_ = blockStyleName;
} else { } else {
throw Error('Invalid style name: ' + blockStyleName); throw Error('Invalid style name: ' + blockStyleName);
} }
@@ -1736,4 +1787,16 @@ export class BlockSvg
traverseJson(json as unknown as {[key: string]: unknown}); traverseJson(json as unknown as {[key: string]: unknown});
return [json]; return [json];
} }
override jsonInit(json: AnyDuringMigration): void {
super.jsonInit(json);
if (json['classes']) {
this.addClass(
Array.isArray(json['classes'])
? json['classes'].join(' ')
: json['classes'],
);
}
}
} }

View File

@@ -17,6 +17,7 @@ import './events/events_var_create.js';
import {Block} from './block.js'; import {Block} from './block.js';
import * as blockAnimations from './block_animations.js'; import * as blockAnimations from './block_animations.js';
import {BlockFlyoutInflater} from './block_flyout_inflater.js';
import {BlockSvg} from './block_svg.js'; import {BlockSvg} from './block_svg.js';
import {BlocklyOptions} from './blockly_options.js'; import {BlocklyOptions} from './blockly_options.js';
import {Blocks} from './blocks.js'; import {Blocks} from './blocks.js';
@@ -24,6 +25,7 @@ import * as browserEvents from './browser_events.js';
import * as bubbles from './bubbles.js'; import * as bubbles from './bubbles.js';
import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js'; import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js';
import * as bumpObjects from './bump_objects.js'; import * as bumpObjects from './bump_objects.js';
import {ButtonFlyoutInflater} from './button_flyout_inflater.js';
import * as clipboard from './clipboard.js'; import * as clipboard from './clipboard.js';
import * as comments from './comments.js'; import * as comments from './comments.js';
import * as common from './common.js'; import * as common from './common.js';
@@ -62,6 +64,7 @@ import {
FieldDropdownConfig, FieldDropdownConfig,
FieldDropdownFromJsonConfig, FieldDropdownFromJsonConfig,
FieldDropdownValidator, FieldDropdownValidator,
ImageProperties,
MenuGenerator, MenuGenerator,
MenuGeneratorFunction, MenuGeneratorFunction,
MenuOption, MenuOption,
@@ -99,7 +102,9 @@ import {
import {Flyout} from './flyout_base.js'; import {Flyout} from './flyout_base.js';
import {FlyoutButton} from './flyout_button.js'; import {FlyoutButton} from './flyout_button.js';
import {HorizontalFlyout} from './flyout_horizontal.js'; import {HorizontalFlyout} from './flyout_horizontal.js';
import {FlyoutItem} from './flyout_item.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutSeparator} from './flyout_separator.js';
import {VerticalFlyout} from './flyout_vertical.js'; import {VerticalFlyout} from './flyout_vertical.js';
import {CodeGenerator} from './generator.js'; import {CodeGenerator} from './generator.js';
import {Gesture} from './gesture.js'; import {Gesture} from './gesture.js';
@@ -107,8 +112,11 @@ import {Grid} from './grid.js';
import * as icons from './icons.js'; import * as icons from './icons.js';
import {inject} from './inject.js'; import {inject} from './inject.js';
import * as inputs from './inputs.js'; import * as inputs from './inputs.js';
import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import {LabelFlyoutInflater} from './label_flyout_inflater.js';
import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js';
import {Input} from './inputs/input.js'; import {Input} from './inputs/input.js';
import {InsertionMarkerManager} from './insertion_marker_manager.js';
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js'; import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
import {IASTNodeLocation} from './interfaces/i_ast_node_location.js'; import {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
import {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js'; import {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
@@ -132,6 +140,8 @@ import {
} from './interfaces/i_draggable.js'; } from './interfaces/i_draggable.js';
import {IDragger} from './interfaces/i_dragger.js'; import {IDragger} from './interfaces/i_dragger.js';
import {IFlyout} from './interfaces/i_flyout.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 {IHasBubble, hasBubble} from './interfaces/i_has_bubble.js';
import {IIcon, isIcon} from './interfaces/i_icon.js'; import {IIcon, isIcon} from './interfaces/i_icon.js';
import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
@@ -155,6 +165,8 @@ import {
IVariableBackedParameterModel, IVariableBackedParameterModel,
isVariableBackedParameterModel, isVariableBackedParameterModel,
} from './interfaces/i_variable_backed_parameter_model.js'; } 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 * as internalConstants from './internal_constants.js';
import {ASTNode} from './keyboard_nav/ast_node.js'; import {ASTNode} from './keyboard_nav/ast_node.js';
import {BasicCursor} from './keyboard_nav/basic_cursor.js'; import {BasicCursor} from './keyboard_nav/basic_cursor.js';
@@ -471,6 +483,8 @@ export {
}; };
export const DropDownDiv = dropDownDiv; export const DropDownDiv = dropDownDiv;
export { export {
BlockFlyoutInflater,
ButtonFlyoutInflater,
CodeGenerator, CodeGenerator,
Field, Field,
FieldCheckbox, FieldCheckbox,
@@ -504,7 +518,9 @@ export {
FieldVariableValidator, FieldVariableValidator,
Flyout, Flyout,
FlyoutButton, FlyoutButton,
FlyoutItem,
FlyoutMetricsManager, FlyoutMetricsManager,
FlyoutSeparator,
CodeGenerator as Generator, CodeGenerator as Generator,
Gesture, Gesture,
Grid, Grid,
@@ -529,6 +545,9 @@ export {
IDraggable, IDraggable,
IDragger, IDragger,
IFlyout, IFlyout,
IFlyoutInflater,
IFocusableNode,
IFocusableTree,
IHasBubble, IHasBubble,
IIcon, IIcon,
IKeyboardAccessible, IKeyboardAccessible,
@@ -546,9 +565,13 @@ export {
IToolbox, IToolbox,
IToolboxItem, IToolboxItem,
IVariableBackedParameterModel, IVariableBackedParameterModel,
IVariableMap,
IVariableModel,
IVariableState,
ImageProperties,
Input, Input,
InsertionMarkerManager,
InsertionMarkerPreviewer, InsertionMarkerPreviewer,
LabelFlyoutInflater,
LayerManager, LayerManager,
Marker, Marker,
MarkerManager, MarkerManager,
@@ -564,6 +587,7 @@ export {
RenderedConnection, RenderedConnection,
Scrollbar, Scrollbar,
ScrollbarPair, ScrollbarPair,
SeparatorFlyoutInflater,
ShortcutRegistry, ShortcutRegistry,
TabNavigateCursor, TabNavigateCursor,
Theme, Theme,

View File

@@ -106,11 +106,7 @@ export abstract class Bubble implements IBubble, ISelectable {
); );
const embossGroup = dom.createSvgElement( const embossGroup = dom.createSvgElement(
Svg.G, Svg.G,
{ {'class': 'blocklyEmboss'},
'filter': `url(#${
this.workspace.getRenderer().getConstants().embossFilterId
})`,
},
this.svgRoot, this.svgRoot,
); );
this.tail = dom.createSvgElement( this.tail = dom.createSvgElement(

View File

@@ -80,6 +80,7 @@ export class MiniWorkspaceBubble extends Bubble {
flyout?.show(options.languageTree); flyout?.show(options.languageTree);
} }
dom.addClass(this.svgRoot, 'blocklyMiniWorkspaceBubble');
this.miniWorkspace.addChangeListener(this.onWorkspaceChange.bind(this)); this.miniWorkspace.addChangeListener(this.onWorkspaceChange.bind(this));
this.miniWorkspace this.miniWorkspace
.getFlyout() .getFlyout()

View File

@@ -27,6 +27,7 @@ export class TextBubble extends Bubble {
super(workspace, anchor, ownerRect); super(workspace, anchor, ownerRect);
this.paragraph = this.stringToSvg(text, this.contentContainer); this.paragraph = this.stringToSvg(text, this.contentContainer);
this.updateBubbleSize(); this.updateBubbleSize();
dom.addClass(this.svgRoot, 'blocklyTextBubble');
} }
/** @returns the current text of this text bubble. */ /** @returns the current text of this text bubble. */

View File

@@ -48,6 +48,9 @@ export class TextInputBubble extends Bubble {
/** Functions listening for changes to the size of this bubble. */ /** Functions listening for changes to the size of this bubble. */
private sizeChangeListeners: (() => void)[] = []; private sizeChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the location of this bubble. */
private locationChangeListeners: (() => void)[] = [];
/** The text of this bubble. */ /** The text of this bubble. */
private text = ''; private text = '';
@@ -123,6 +126,11 @@ export class TextInputBubble extends Bubble {
this.sizeChangeListeners.push(listener); this.sizeChangeListeners.push(listener);
} }
/** Adds a change listener to be notified when this bubble's location changes. */
addLocationChangeListener(listener: () => void) {
this.locationChangeListeners.push(listener);
}
/** Creates the editor UI for this bubble. */ /** Creates the editor UI for this bubble. */
private createEditor(container: SVGGElement): { private createEditor(container: SVGGElement): {
inputRoot: SVGForeignObjectElement; inputRoot: SVGForeignObjectElement;
@@ -230,10 +238,25 @@ export class TextInputBubble extends Bubble {
/** @returns the size of this bubble. */ /** @returns the size of this bubble. */
getSize(): Size { getSize(): Size {
// Overriden to be public. // Overridden to be public.
return super.getSize(); 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. */ /** Handles mouse down events on the resize target. */
private onResizePointerDown(e: PointerEvent) { private onResizePointerDown(e: PointerEvent) {
this.bringToFront(); this.bringToFront();
@@ -316,6 +339,13 @@ export class TextInputBubble extends Bubble {
listener(); listener();
} }
} }
/** Handles a location change event for the text area. Calls event listeners. */
private onLocationChange() {
for (const listener of this.locationChangeListeners) {
listener();
}
}
} }
Css.register(` Css.register(`

View File

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

View File

@@ -6,7 +6,7 @@
// Former goog.module ID: Blockly.clipboard // 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 * as registry from './clipboard/registry.js';
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
import * as globalRegistry from './registry.js'; import * as globalRegistry from './registry.js';
@@ -112,4 +112,4 @@ export const TEST_ONLY = {
copyInternal, copyInternal,
}; };
export {BlockPaster, registry}; export {BlockCopyData, BlockPaster, registry};

View File

@@ -53,7 +53,7 @@ export class CommentView implements IRenderedElement {
private textArea: HTMLTextAreaElement; private textArea: HTMLTextAreaElement;
/** The current size of the comment in workspace units. */ /** 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. */ /** Whether the comment is collapsed or not. */
private collapsed: boolean = false; private collapsed: boolean = false;
@@ -95,15 +95,18 @@ export class CommentView implements IRenderedElement {
private resizePointerMoveListener: browserEvents.Data | null = null; private resizePointerMoveListener: browserEvents.Data | null = null;
/** Whether this comment view is currently being disposed or not. */ /** 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. */ /** 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. */ /** Size of this comment when the resize drag was initiated. */
private preResizeSize?: Size; 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, { this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable', 'class': 'blocklyComment blocklyEditable blocklyDraggable',
}); });
@@ -129,6 +132,7 @@ export class CommentView implements IRenderedElement {
workspace.getLayerManager()?.append(this, layers.BLOCK); workspace.getLayerManager()?.append(this, layers.BLOCK);
// Set size to the default size. // Set size to the default size.
this.size = CommentView.defaultCommentSize;
this.setSizeWithoutFiringEvents(this.size); this.setSizeWithoutFiringEvents(this.size);
// Set default transform (including inverted scale for RTL). // Set default transform (including inverted scale for RTL).
@@ -685,6 +689,11 @@ export class CommentView implements IRenderedElement {
this.onTextChange(); 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. */ /** Registers a callback that listens for text changes. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) { addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener); this.textChangeListeners.push(listener);

View File

@@ -105,6 +105,11 @@ export class RenderedWorkspaceComment
this.view.setText(text); 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. */ /** Sets the size of the comment. */
override setSize(size: Size) { override setSize(size: Size) {
// setSize will trigger the change listener that updates // setSize will trigger the change listener that updates

View File

@@ -12,6 +12,7 @@ import {Coordinate} from '../utils/coordinate.js';
import * as idGenerator from '../utils/idgenerator.js'; import * as idGenerator from '../utils/idgenerator.js';
import {Size} from '../utils/size.js'; import {Size} from '../utils/size.js';
import {Workspace} from '../workspace.js'; import {Workspace} from '../workspace.js';
import {CommentView} from './comment_view.js';
export class WorkspaceComment { export class WorkspaceComment {
/** The unique identifier for this comment. */ /** The unique identifier for this comment. */
@@ -21,7 +22,7 @@ export class WorkspaceComment {
private text = ''; private text = '';
/** The size of the comment in workspace units. */ /** The size of the comment in workspace units. */
private size = new Size(120, 100); private size: Size;
/** Whether the comment is collapsed or not. */ /** Whether the comment is collapsed or not. */
private collapsed = false; private collapsed = false;
@@ -56,6 +57,7 @@ export class WorkspaceComment {
id?: string, id?: string,
) { ) {
this.id = id && !workspace.getCommentById(id) ? id : idGenerator.genUid(); this.id = id && !workspace.getCommentById(id) ? id : idGenerator.genUid();
this.size = CommentView.defaultCommentSize;
workspace.addTopComment(this); workspace.addTopComment(this);
@@ -142,7 +144,7 @@ export class WorkspaceComment {
* workspace is read-only. * workspace is read-only.
*/ */
isEditable(): boolean { 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. * workspace is read-only.
*/ */
isMovable() { isMovable() {
return this.isOwnMovable() && !this.workspace.options.readOnly; return this.isOwnMovable() && !this.workspace.isReadOnly();
} }
/** /**
@@ -187,7 +189,7 @@ export class WorkspaceComment {
return ( return (
this.isOwnDeletable() && this.isOwnDeletable() &&
!this.isDeadOrDying() && !this.isDeadOrDying() &&
!this.workspace.options.readOnly !this.workspace.isReadOnly()
); );
} }

View File

@@ -485,7 +485,7 @@ export class Connection implements IASTNodeLocationWithBlock {
* *
* Headless configurations (the default) do not have neighboring connection, * Headless configurations (the default) do not have neighboring connection,
* and always return an empty list (the default). * 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. * computed from the rendered positioning.
* *
* @param _maxLimit The maximum radius to another connection. * @param _maxLimit The maximum radius to another connection.

View File

@@ -18,6 +18,7 @@ import type {
import {EventType} from './events/type.js'; import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js'; import * as eventUtils from './events/utils.js';
import {Menu} from './menu.js'; import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js'; import {MenuItem} from './menuitem.js';
import * as serializationBlocks from './serialization/blocks.js'; import * as serializationBlocks from './serialization/blocks.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
@@ -111,6 +112,11 @@ function populate_(
menu.setRole(aria.Role.MENU); menu.setRole(aria.Role.MENU);
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const option = options[i]; const option = options[i];
if (option.separator) {
menu.addChild(new MenuSeparator());
continue;
}
const menuItem = new MenuItem(option.text); const menuItem = new MenuItem(option.text);
menuItem.setRightToLeft(rtl); menuItem.setRightToLeft(rtl);
menuItem.setRole(aria.Role.MENUITEM); menuItem.setRole(aria.Role.MENUITEM);

View File

@@ -619,7 +619,7 @@ export function registerCommentCreate() {
if (!workspace) return; if (!workspace) return;
eventUtils.setGroup(true); eventUtils.setGroup(true);
const comment = new RenderedWorkspaceComment(workspace); const comment = new RenderedWorkspaceComment(workspace);
comment.setText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']); comment.setPlaceholderText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']);
comment.moveTo( comment.moveTo(
pixelsToWorkspaceCoords( pixelsToWorkspaceCoords(
new Coordinate(e.clientX, e.clientY), new Coordinate(e.clientX, e.clientY),

View File

@@ -87,21 +87,37 @@ export class ContextMenuRegistry {
const menuOptions: ContextMenuOption[] = []; const menuOptions: ContextMenuOption[] = [];
for (const item of this.registeredItems.values()) { for (const item of this.registeredItems.values()) {
if (scopeType === item.scopeType) { if (scopeType === item.scopeType) {
const precondition = item.preconditionFn(scope); let menuOption:
if (precondition !== 'hidden') { | ContextMenuRegistry.CoreContextMenuOption
| ContextMenuRegistry.SeparatorContextMenuOption
| ContextMenuRegistry.ActionContextMenuOption;
menuOption = {
scope,
weight: item.weight,
};
if (item.separator) {
menuOption = {
...menuOption,
separator: true,
};
} else {
const precondition = item.preconditionFn(scope);
if (precondition === 'hidden') continue;
const displayText = const displayText =
typeof item.displayText === 'function' typeof item.displayText === 'function'
? item.displayText(scope) ? item.displayText(scope)
: item.displayText; : item.displayText;
const menuOption: ContextMenuOption = { menuOption = {
...menuOption,
text: displayText, text: displayText,
enabled: precondition === 'enabled',
callback: item.callback, callback: item.callback,
scope, enabled: precondition === 'enabled',
weight: item.weight,
}; };
menuOptions.push(menuOption);
} }
menuOptions.push(menuOption);
} }
} }
menuOptions.sort(function (a, b) { menuOptions.sort(function (a, b) {
@@ -134,9 +150,18 @@ export namespace ContextMenuRegistry {
} }
/** /**
* A menu item as entered in the registry. * Fields common to all context menu registry items.
*/ */
export interface RegistryItem { interface CoreRegistryItem {
scopeType: ScopeType;
weight: number;
id: string;
}
/**
* A representation of a normal, clickable menu item in the registry.
*/
interface ActionRegistryItem extends CoreRegistryItem {
/** /**
* @param scope Object that provides a reference to the thing that had its * @param scope Object that provides a reference to the thing that had its
* context menu opened. * context menu opened.
@@ -144,17 +169,38 @@ export namespace ContextMenuRegistry {
* the event that triggered the click on the option. * the event that triggered the click on the option.
*/ */
callback: (scope: Scope, e: PointerEvent) => void; callback: (scope: Scope, e: PointerEvent) => void;
scopeType: ScopeType;
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement; displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
preconditionFn: (p1: Scope) => string; preconditionFn: (p1: Scope) => string;
weight: number; separator?: never;
id: string;
} }
/** /**
* A menu item as presented to contextmenu.js. * A representation of a menu separator item in the registry.
*/ */
export interface ContextMenuOption { 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; text: string | HTMLElement;
enabled: boolean; enabled: boolean;
/** /**
@@ -164,10 +210,26 @@ export namespace ContextMenuRegistry {
* the event that triggered the click on the option. * the event that triggered the click on the option.
*/ */
callback: (scope: Scope, e: PointerEvent) => void; callback: (scope: Scope, e: PointerEvent) => void;
scope: Scope; separator?: never;
weight: number;
} }
/**
* 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 * A subset of ContextMenuOption corresponding to what was publicly
* documented. ContextMenuOption should be preferred for new code. * documented. ContextMenuOption should be preferred for new code.
@@ -176,6 +238,7 @@ export namespace ContextMenuRegistry {
text: string; text: string;
enabled: boolean; enabled: boolean;
callback: (p1: Scope) => void; callback: (p1: Scope) => void;
separator?: never;
} }
/** /**

View File

@@ -5,7 +5,6 @@
*/ */
// Former goog.module ID: Blockly.Css // Former goog.module ID: Blockly.Css
/** Has CSS already been injected? */ /** Has CSS already been injected? */
let injected = false; let injected = false;
@@ -83,17 +82,15 @@ let content = `
-webkit-user-select: none; -webkit-user-select: none;
} }
.blocklyNonSelectable {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.blocklyBlockCanvas.blocklyCanvasTransitioning, .blocklyBlockCanvas.blocklyCanvasTransitioning,
.blocklyBubbleCanvas.blocklyCanvasTransitioning { .blocklyBubbleCanvas.blocklyCanvasTransitioning {
transition: transform .5s; transition: transform .5s;
} }
.blocklyEmboss {
filter: var(--blocklyEmbossFilter);
}
.blocklyTooltipDiv { .blocklyTooltipDiv {
background-color: #ffffc7; background-color: #ffffc7;
border: 1px solid #ddc; border: 1px solid #ddc;
@@ -121,7 +118,7 @@ let content = `
box-shadow: 0 0 3px 1px rgba(0,0,0,.3); 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); box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
} }
@@ -141,47 +138,14 @@ let content = `
z-index: -1; z-index: -1;
background-color: inherit; background-color: inherit;
border-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-top: 1px solid;
border-left: 1px solid; border-left: 1px solid;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-color: inherit; border-color: inherit;
} }
.blocklyArrowBottom { .blocklyHighlighted>.blocklyPath {
border-bottom: 1px solid; filter: var(--blocklyEmbossFilter);
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;
} }
.blocklyHighlightedConnectionPath { .blocklyHighlightedConnectionPath {
@@ -235,6 +199,7 @@ let content = `
} }
.blocklyDisabled>.blocklyPath { .blocklyDisabled>.blocklyPath {
fill: var(--blocklyDisabledPattern);
fill-opacity: .5; fill-opacity: .5;
stroke-opacity: .5; stroke-opacity: .5;
} }
@@ -251,7 +216,7 @@ let content = `
stroke: none; stroke: none;
} }
.blocklyNonEditableText>text { .blocklyNonEditableField>text {
pointer-events: none; pointer-events: none;
} }
@@ -264,12 +229,15 @@ let content = `
cursor: default; cursor: default;
} }
.blocklyHidden { /*
display: none; Don't allow users to select text. It gets annoying when trying to
} drag a block and selected text moves instead.
*/
.blocklyFieldDropdown:not(.blocklyHidden) { .blocklySvg text {
display: block; user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
cursor: inherit;
} }
.blocklyIconGroup { .blocklyIconGroup {
@@ -419,6 +387,9 @@ input[type=number] {
} }
.blocklyWidgetDiv .blocklyMenu { .blocklyWidgetDiv .blocklyMenu {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
background: #fff; background: #fff;
border: 1px solid transparent; border: 1px solid transparent;
box-shadow: 0 0 3px 1px rgba(0,0,0,.3); box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
@@ -433,11 +404,14 @@ input[type=number] {
z-index: 20000; /* Arbitrary, but some apps depend on it... */ 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); box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
} }
.blocklyDropDownDiv .blocklyMenu { .blocklyDropDownDiv .blocklyMenu {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
background: inherit; /* Compatibility with gapi, reset from goog-menu */ background: inherit; /* Compatibility with gapi, reset from goog-menu */
border: 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; font: normal 13px "Helvetica Neue", Helvetica, sans-serif;
@@ -465,8 +439,7 @@ input[type=number] {
cursor: inherit; cursor: inherit;
} }
/* State: hover. */ .blocklyMenuItem:hover {
.blocklyMenuItemHighlight {
background-color: rgba(0,0,0,.1); background-color: rgba(0,0,0,.1);
} }
@@ -489,6 +462,14 @@ input[type=number] {
margin-right: -24px; margin-right: -24px;
} }
.blocklyMenuSeparator {
background-color: #ccc;
height: 1px;
border: 0;
margin-left: 4px;
margin-right: 4px;
}
.blocklyBlockDragSurface, .blocklyAnimationLayer { .blocklyBlockDragSurface, .blocklyAnimationLayer {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -499,4 +480,17 @@ input[type=number] {
z-index: 80; z-index: 80;
pointer-events: none; pointer-events: none;
} }
.blocklyField {
cursor: default;
}
.blocklyInputField {
cursor: text;
}
.blocklyDragging .blocklyField,
.blocklyDragging .blocklyIconGroup {
cursor: grabbing;
}
`; `;

View File

@@ -62,8 +62,8 @@ export class BlockDragStrategy implements IDragStrategy {
*/ */
private dragOffset = new Coordinate(0, 0); private dragOffset = new Coordinate(0, 0);
/** Was there already an event group in progress when the drag started? */ /** Used to persist an event group when snapping is done async. */
private inGroup: boolean = false; private originalEventGroup = '';
constructor(private block: BlockSvg) { constructor(private block: BlockSvg) {
this.workspace = block.workspace; this.workspace = block.workspace;
@@ -78,7 +78,7 @@ export class BlockDragStrategy implements IDragStrategy {
return ( return (
this.block.isOwnMovable() && this.block.isOwnMovable() &&
!this.block.isDeadOrDying() && !this.block.isDeadOrDying() &&
!this.workspace.options.readOnly && !this.workspace.isReadOnly() &&
// We never drag blocks in the flyout, only create new blocks that are // We never drag blocks in the flyout, only create new blocks that are
// dragged. // dragged.
!this.block.isInFlyout !this.block.isInFlyout
@@ -96,10 +96,6 @@ export class BlockDragStrategy implements IDragStrategy {
} }
this.dragging = true; this.dragging = true;
this.inGroup = !!eventUtils.getGroup();
if (!this.inGroup) {
eventUtils.setGroup(true);
}
this.fireDragStartEvent(); this.fireDragStartEvent();
this.startLoc = this.block.getRelativeToSurfaceXY(); this.startLoc = this.block.getRelativeToSurfaceXY();
@@ -363,6 +359,7 @@ export class BlockDragStrategy implements IDragStrategy {
this.block.getParent()?.endDrag(e); this.block.getParent()?.endDrag(e);
return; return;
} }
this.originalEventGroup = eventUtils.getGroup();
this.fireDragEndEvent(); this.fireDragEndEvent();
this.fireMoveEvent(); this.fireMoveEvent();
@@ -388,20 +385,19 @@ export class BlockDragStrategy implements IDragStrategy {
} else { } else {
this.block.queueRender().then(() => this.disposeStep()); this.block.queueRender().then(() => this.disposeStep());
} }
if (!this.inGroup) {
eventUtils.setGroup(false);
}
} }
/** Disposes of any state at the end of the drag. */ /** Disposes of any state at the end of the drag. */
private disposeStep() { private disposeStep() {
const newGroup = eventUtils.getGroup();
eventUtils.setGroup(this.originalEventGroup);
this.block.snapToGrid(); this.block.snapToGrid();
// Must dispose after connections are applied to not break the dynamic // Must dispose after connections are applied to not break the dynamic
// connections plugin. See #7859 // connections plugin. See #7859
this.connectionPreviewer!.dispose(); this.connectionPreviewer!.dispose();
this.workspace.setResizesEnabled(true); this.workspace.setResizesEnabled(true);
eventUtils.setGroup(newGroup);
} }
/** Connects the given candidate connections. */ /** Connects the given candidate connections. */

View File

@@ -5,7 +5,6 @@
*/ */
import {IBubble, WorkspaceSvg} from '../blockly.js'; import {IBubble, WorkspaceSvg} from '../blockly.js';
import * as eventUtils from '../events/utils.js';
import {IDragStrategy} from '../interfaces/i_draggable.js'; import {IDragStrategy} from '../interfaces/i_draggable.js';
import * as layers from '../layers.js'; import * as layers from '../layers.js';
import {Coordinate} from '../utils.js'; import {Coordinate} from '../utils.js';
@@ -13,9 +12,6 @@ import {Coordinate} from '../utils.js';
export class BubbleDragStrategy implements IDragStrategy { export class BubbleDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null; private startLoc: Coordinate | null = null;
/** Was there already an event group in progress when the drag started? */
private inGroup: boolean = false;
constructor( constructor(
private bubble: IBubble, private bubble: IBubble,
private workspace: WorkspaceSvg, private workspace: WorkspaceSvg,
@@ -26,10 +22,6 @@ export class BubbleDragStrategy implements IDragStrategy {
} }
startDrag(): void { startDrag(): void {
this.inGroup = !!eventUtils.getGroup();
if (!this.inGroup) {
eventUtils.setGroup(true);
}
this.startLoc = this.bubble.getRelativeToSurfaceXY(); this.startLoc = this.bubble.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false); this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.bubble); this.workspace.getLayerManager()?.moveToDragLayer(this.bubble);
@@ -44,9 +36,6 @@ export class BubbleDragStrategy implements IDragStrategy {
endDrag(): void { endDrag(): void {
this.workspace.setResizesEnabled(true); this.workspace.setResizesEnabled(true);
if (!this.inGroup) {
eventUtils.setGroup(false);
}
this.workspace this.workspace
.getLayerManager() .getLayerManager()

View File

@@ -18,9 +18,6 @@ export class CommentDragStrategy implements IDragStrategy {
private workspace: WorkspaceSvg; private workspace: WorkspaceSvg;
/** Was there already an event group in progress when the drag started? */
private inGroup: boolean = false;
constructor(private comment: RenderedWorkspaceComment) { constructor(private comment: RenderedWorkspaceComment) {
this.workspace = comment.workspace; this.workspace = comment.workspace;
} }
@@ -29,15 +26,11 @@ export class CommentDragStrategy implements IDragStrategy {
return ( return (
this.comment.isOwnMovable() && this.comment.isOwnMovable() &&
!this.comment.isDeadOrDying() && !this.comment.isDeadOrDying() &&
!this.workspace.options.readOnly !this.workspace.isReadOnly()
); );
} }
startDrag(): void { startDrag(): void {
this.inGroup = !!eventUtils.getGroup();
if (!this.inGroup) {
eventUtils.setGroup(true);
}
this.fireDragStartEvent(); this.fireDragStartEvent();
this.startLoc = this.comment.getRelativeToSurfaceXY(); this.startLoc = this.comment.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false); this.workspace.setResizesEnabled(false);
@@ -61,9 +54,6 @@ export class CommentDragStrategy implements IDragStrategy {
this.comment.snapToGrid(); this.comment.snapToGrid();
this.workspace.setResizesEnabled(true); this.workspace.setResizesEnabled(true);
if (!this.inGroup) {
eventUtils.setGroup(false);
}
} }
/** Fire a UI event at the start of a comment drag. */ /** Fire a UI event at the start of a comment drag. */

View File

@@ -31,6 +31,9 @@ export class Dragger implements IDragger {
/** Handles any drag startup. */ /** Handles any drag startup. */
onDragStart(e: PointerEvent) { onDragStart(e: PointerEvent) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.draggable.startDrag(e); this.draggable.startDrag(e);
} }
@@ -119,13 +122,13 @@ export class Dragger implements IDragger {
this.draggable.endDrag(e); this.draggable.endDrag(e);
if (wouldDelete && isDeletable(root)) { if (wouldDelete && isDeletable(root)) {
// We want to make sure the delete gets grouped with any possible // We want to make sure the delete gets grouped with any possible move
// move event. // event. In core Blockly this shouldn't happen, but due to a change
const newGroup = eventUtils.getGroup(); // in behavior older custom draggables might still clear the group.
eventUtils.setGroup(origGroup); eventUtils.setGroup(origGroup);
root.dispose(); root.dispose();
eventUtils.setGroup(newGroup);
} }
eventUtils.setGroup(false);
} }
// We need to special case blocks for now so that we look at the root block // We need to special case blocks for now so that we look at the root block

View File

@@ -136,12 +136,6 @@ export function createDom() {
// Handle focusin/out events to add a visual indicator when // Handle focusin/out events to add a visual indicator when
// a child is focused or blurred. // a child is focused or blurred.
div.addEventListener('focusin', function () {
dom.addClass(div, 'blocklyFocused');
});
div.addEventListener('focusout', function () {
dom.removeClass(div, 'blocklyFocused');
});
} }
/** /**
@@ -166,7 +160,7 @@ export function getOwner(): Field | null {
* *
* @returns Div to populate with content. * @returns Div to populate with content.
*/ */
export function getContentDiv(): Element { export function getContentDiv(): HTMLDivElement {
return content; return content;
} }
@@ -703,19 +697,12 @@ function positionInternal(
// Update arrow CSS. // Update arrow CSS.
if (metrics.arrowVisible) { if (metrics.arrowVisible) {
const x = metrics.arrowX;
const y = metrics.arrowY;
const rotation = metrics.arrowAtTop ? 45 : 225;
arrow.style.display = ''; arrow.style.display = '';
arrow.style.transform = arrow.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
'translate(' + arrow.setAttribute('class', 'blocklyDropDownArrow');
metrics.arrowX +
'px,' +
metrics.arrowY +
'px) rotate(45deg)';
arrow.setAttribute(
'class',
metrics.arrowAtTop
? 'blocklyDropDownArrow blocklyArrowTop'
: 'blocklyDropDownArrow blocklyArrowBottom',
);
} else { } else {
arrow.style.display = 'none'; arrow.style.display = 'none';
} }

View File

@@ -40,12 +40,15 @@ export {
ToolboxItemSelect, ToolboxItemSelect,
ToolboxItemSelectJson, ToolboxItemSelectJson,
} from './events_toolbox_item_select.js'; } from './events_toolbox_item_select.js';
// Events.
export {TrashcanOpen, TrashcanOpenJson} from './events_trashcan_open.js'; export {TrashcanOpen, TrashcanOpenJson} from './events_trashcan_open.js';
export {UiBase} from './events_ui_base.js'; export {UiBase} from './events_ui_base.js';
export {VarBase, VarBaseJson} from './events_var_base.js'; export {VarBase, VarBaseJson} from './events_var_base.js';
export {VarCreate, VarCreateJson} from './events_var_create.js'; export {VarCreate, VarCreateJson} from './events_var_create.js';
export {VarDelete, VarDeleteJson} from './events_var_delete.js'; export {VarDelete, VarDeleteJson} from './events_var_delete.js';
export {VarRename, VarRenameJson} from './events_var_rename.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 {ViewportChange, ViewportChangeJson} from './events_viewport.js';
export {FinishedLoading} from './workspace_events.js'; export {FinishedLoading} from './workspace_events.js';

View File

@@ -11,7 +11,10 @@
*/ */
// Former goog.module ID: Blockly.Events.VarBase // 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 type {Workspace} from '../workspace.js';
import { import {
Abstract as AbstractEvent, Abstract as AbstractEvent,
@@ -30,13 +33,13 @@ export class VarBase extends AbstractEvent {
* @param opt_variable The variable this event corresponds to. Undefined for * @param opt_variable The variable this event corresponds to. Undefined for
* a blank event. * a blank event.
*/ */
constructor(opt_variable?: VariableModel) { constructor(opt_variable?: IVariableModel<IVariableState>) {
super(); super();
this.isBlank = typeof opt_variable === 'undefined'; this.isBlank = typeof opt_variable === 'undefined';
if (!opt_variable) return; if (!opt_variable) return;
this.varId = opt_variable.getId(); this.varId = opt_variable.getId();
this.workspaceId = opt_variable.workspace.id; this.workspaceId = opt_variable.getWorkspace().id;
} }
/** /**

View File

@@ -11,8 +11,12 @@
*/ */
// Former goog.module ID: Blockly.Events.VarCreate // Former goog.module ID: Blockly.Events.VarCreate
import type {
IVariableModel,
IVariableState,
} from '../interfaces/i_variable_model.js';
import * as registry from '../registry.js'; import * as registry from '../registry.js';
import type {VariableModel} from '../variable_model.js';
import type {Workspace} from '../workspace.js'; import type {Workspace} from '../workspace.js';
import {VarBase, VarBaseJson} from './events_var_base.js'; import {VarBase, VarBaseJson} from './events_var_base.js';
import {EventType} from './type.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. * @param opt_variable The created variable. Undefined for a blank event.
*/ */
constructor(opt_variable?: VariableModel) { constructor(opt_variable?: IVariableModel<IVariableState>) {
super(opt_variable); super(opt_variable);
if (!opt_variable) { if (!opt_variable) {
return; // Blank event to be populated by fromJson. return; // Blank event to be populated by fromJson.
} }
this.varType = opt_variable.type; this.varType = opt_variable.getType();
this.varName = opt_variable.name; this.varName = opt_variable.getName();
} }
/** /**

View File

@@ -6,16 +6,18 @@
// Former goog.module ID: Blockly.Events.VarDelete // Former goog.module ID: Blockly.Events.VarDelete
import type {
IVariableModel,
IVariableState,
} from '../interfaces/i_variable_model.js';
import * as registry from '../registry.js'; import * as registry from '../registry.js';
import type {VariableModel} from '../variable_model.js';
import type {Workspace} from '../workspace.js'; import type {Workspace} from '../workspace.js';
import {VarBase, VarBaseJson} from './events_var_base.js'; import {VarBase, VarBaseJson} from './events_var_base.js';
import {EventType} from './type.js'; import {EventType} from './type.js';
/** /**
* Notifies listeners that a variable model has been deleted. * Notifies listeners that a variable model has been deleted.
*
* @class
*/ */
export class VarDelete extends VarBase { export class VarDelete extends VarBase {
override type = EventType.VAR_DELETE; 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. * @param opt_variable The deleted variable. Undefined for a blank event.
*/ */
constructor(opt_variable?: VariableModel) { constructor(opt_variable?: IVariableModel<IVariableState>) {
super(opt_variable); super(opt_variable);
if (!opt_variable) { if (!opt_variable) {
return; // Blank event to be populated by fromJson. return; // Blank event to be populated by fromJson.
} }
this.varType = opt_variable.type; this.varType = opt_variable.getType();
this.varName = opt_variable.name; this.varName = opt_variable.getName();
} }
/** /**

View File

@@ -6,16 +6,18 @@
// Former goog.module ID: Blockly.Events.VarRename // Former goog.module ID: Blockly.Events.VarRename
import type {
IVariableModel,
IVariableState,
} from '../interfaces/i_variable_model.js';
import * as registry from '../registry.js'; import * as registry from '../registry.js';
import type {VariableModel} from '../variable_model.js';
import type {Workspace} from '../workspace.js'; import type {Workspace} from '../workspace.js';
import {VarBase, VarBaseJson} from './events_var_base.js'; import {VarBase, VarBaseJson} from './events_var_base.js';
import {EventType} from './type.js'; import {EventType} from './type.js';
/** /**
* Notifies listeners that a variable model was renamed. * Notifies listeners that a variable model was renamed.
*
* @class
*/ */
export class VarRename extends VarBase { export class VarRename extends VarBase {
override type = EventType.VAR_RENAME; 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 opt_variable The renamed variable. Undefined for a blank event.
* @param newName The new name the variable will be changed to. * @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); super(opt_variable);
if (!opt_variable) { if (!opt_variable) {
return; // Blank event to be populated by fromJson. return; // Blank event to be populated by fromJson.
} }
this.oldName = opt_variable.name; this.oldName = opt_variable.getName();
this.newName = typeof newName === 'undefined' ? '' : newName; this.newName = typeof newName === 'undefined' ? '' : newName;
} }

View File

@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Class for a variable type change event.
*
* @class
*/
import type {
IVariableModel,
IVariableState,
} from '../interfaces/i_variable_model.js';
import * as registry from '../registry.js';
import type {Workspace} from '../workspace.js';
import {VarBase, VarBaseJson} from './events_var_base.js';
import {EventType} from './type.js';
/**
* Notifies listeners that a variable's type has changed.
*/
export class VarTypeChange extends VarBase {
override type = EventType.VAR_TYPE_CHANGE;
/**
* @param variable The variable whose type changed. Undefined for a blank event.
* @param oldType The old type of the variable. Undefined for a blank event.
* @param newType The new type of the variable. Undefined for a blank event.
*/
constructor(
variable?: IVariableModel<IVariableState>,
public oldType?: string,
public newType?: string,
) {
super(variable);
}
/**
* Encode the event as JSON.
*
* @returns JSON representation.
*/
override toJson(): VarTypeChangeJson {
const json = super.toJson() as VarTypeChangeJson;
if (!this.oldType || !this.newType) {
throw new Error(
"The variable's types are undefined. Either pass them to " +
'the constructor, or call fromJson',
);
}
json['oldType'] = this.oldType;
json['newType'] = this.newType;
return json;
}
/**
* Deserializes the JSON event.
*
* @param event The event to append new properties to. Should be a subclass
* of VarTypeChange, but we can't specify that due to the fact that
* parameters to static methods in subclasses must be supertypes of
* parameters to static methods in superclasses.
* @internal
*/
static fromJson(
json: VarTypeChangeJson,
workspace: Workspace,
event?: any,
): VarTypeChange {
const newEvent = super.fromJson(
json,
workspace,
event ?? new VarTypeChange(),
) as VarTypeChange;
newEvent.oldType = json['oldType'];
newEvent.newType = json['newType'];
return newEvent;
}
/**
* Run a variable type change event.
*
* @param forward True if run forward, false if run backward (undo).
*/
override run(forward: boolean) {
const workspace = this.getEventWorkspace_();
if (!this.varId) {
throw new Error(
'The var ID is undefined. Either pass a variable to ' +
'the constructor, or call fromJson',
);
}
if (!this.oldType || !this.newType) {
throw new Error(
"The variable's types are undefined. Either pass them to " +
'the constructor, or call fromJson',
);
}
const variable = workspace.getVariableMap().getVariableById(this.varId);
if (!variable) return;
if (forward) {
workspace.getVariableMap().changeVariableType(variable, this.newType);
} else {
workspace.getVariableMap().changeVariableType(variable, this.oldType);
}
}
}
export interface VarTypeChangeJson extends VarBaseJson {
oldType: string;
newType: string;
}
registry.register(
registry.Type.EVENT,
EventType.VAR_TYPE_CHANGE,
VarTypeChange,
);

View File

@@ -28,6 +28,8 @@ export enum EventType {
VAR_DELETE = 'var_delete', VAR_DELETE = 'var_delete',
/** Type of event that renames a variable. */ /** Type of event that renames a variable. */
VAR_RENAME = 'var_rename', 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. * Type of generic event that records a UI change.
* *

View File

@@ -437,7 +437,10 @@ function checkDropdownOptionsInTable(
} }
const options = dropdown.getOptions(); 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) { if (lookupTable[key] === undefined) {
console.warn( console.warn(
`No tooltip mapping for value ${key} of field ` + `No tooltip mapping for value ${key} of field ` +

View File

@@ -83,9 +83,6 @@ export abstract class Field<T = any>
*/ */
DEFAULT_VALUE: T | null = null; DEFAULT_VALUE: T | null = null;
/** Non-breaking space. */
static readonly NBSP = '\u00A0';
/** /**
* A value used to signal when a field's constructor should *not* set the * A value used to signal when a field's constructor should *not* set the
* field's value or run configure_, and should allow a subclass to do that * field's value or run configure_, and should allow a subclass to do that
@@ -194,9 +191,6 @@ export abstract class Field<T = any>
*/ */
SERIALIZABLE = false; SERIALIZABLE = false;
/** Mouse cursor style when over the hotspot that initiates the editor. */
CURSOR = '';
/** /**
* @param value The initial value of the field. * @param value The initial value of the field.
* Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
@@ -324,6 +318,9 @@ export abstract class Field<T = any>
protected initView() { protected initView() {
this.createBorderRect_(); this.createBorderRect_();
this.createTextElement_(); this.createTextElement_();
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyField');
}
} }
/** /**
@@ -374,7 +371,7 @@ export abstract class Field<T = any>
this.textElement_ = dom.createSvgElement( this.textElement_ = dom.createSvgElement(
Svg.TEXT, Svg.TEXT,
{ {
'class': 'blocklyText', 'class': 'blocklyText blocklyFieldText',
}, },
this.fieldGroup_, this.fieldGroup_,
); );
@@ -406,7 +403,6 @@ export abstract class Field<T = any>
* called by Blockly.Xml. * called by Blockly.Xml.
* *
* @param fieldElement The element containing info about the field's state. * @param fieldElement The element containing info about the field's state.
* @internal
*/ */
fromXml(fieldElement: Element) { fromXml(fieldElement: Element) {
// Any because gremlins live here. No touchie! // Any because gremlins live here. No touchie!
@@ -419,7 +415,6 @@ export abstract class Field<T = any>
* @param fieldElement The element to populate with info about the field's * @param fieldElement The element to populate with info about the field's
* state. * state.
* @returns The element containing info about the field's state. * @returns The element containing info about the field's state.
* @internal
*/ */
toXml(fieldElement: Element): Element { toXml(fieldElement: Element): Element {
// Any because gremlins live here. No touchie! // Any because gremlins live here. No touchie!
@@ -438,7 +433,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} * {@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. * for more information.
* @returns JSON serializable state. * @returns JSON serializable state.
* @internal
*/ */
saveState(_doFullSerialization?: boolean): AnyDuringMigration { saveState(_doFullSerialization?: boolean): AnyDuringMigration {
const legacyState = this.saveLegacyState(Field); const legacyState = this.saveLegacyState(Field);
@@ -453,7 +447,6 @@ export abstract class Field<T = any>
* called by the serialization system. * called by the serialization system.
* *
* @param state The state we want to apply to the field. * @param state The state we want to apply to the field.
* @internal
*/ */
loadState(state: AnyDuringMigration) { loadState(state: AnyDuringMigration) {
if (this.loadLegacyState(Field, state)) { if (this.loadLegacyState(Field, state)) {
@@ -516,8 +509,6 @@ export abstract class Field<T = any>
/** /**
* Dispose of all DOM objects and events belonging to this editable field. * Dispose of all DOM objects and events belonging to this editable field.
*
* @internal
*/ */
dispose() { dispose() {
dropDownDiv.hideIfOwner(this); dropDownDiv.hideIfOwner(this);
@@ -538,13 +529,11 @@ export abstract class Field<T = any>
return; return;
} }
if (this.enabled_ && block.isEditable()) { if (this.enabled_ && block.isEditable()) {
dom.addClass(group, 'blocklyEditableText'); dom.addClass(group, 'blocklyEditableField');
dom.removeClass(group, 'blocklyNonEditableText'); dom.removeClass(group, 'blocklyNonEditableField');
group.style.cursor = this.CURSOR;
} else { } else {
dom.addClass(group, 'blocklyNonEditableText'); dom.addClass(group, 'blocklyNonEditableField');
dom.removeClass(group, 'blocklyEditableText'); dom.removeClass(group, 'blocklyEditableField');
group.style.cursor = '';
} }
} }
@@ -833,12 +822,7 @@ export abstract class Field<T = any>
let contentWidth = 0; let contentWidth = 0;
if (this.textElement_) { if (this.textElement_) {
contentWidth = dom.getFastTextWidth( contentWidth = dom.getTextWidth(this.textElement_);
this.textElement_,
constants!.FIELD_TEXT_FONTSIZE,
constants!.FIELD_TEXT_FONTWEIGHT,
constants!.FIELD_TEXT_FONTFAMILY,
);
totalWidth += contentWidth; totalWidth += contentWidth;
} }
if (!this.isFullBlockField()) { if (!this.isFullBlockField()) {
@@ -918,17 +902,6 @@ export abstract class Field<T = any>
if (this.isDirty_) { if (this.isDirty_) {
this.render_(); this.render_();
this.isDirty_ = false; 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_; return this.size_;
} }
@@ -992,16 +965,10 @@ export abstract class Field<T = any>
*/ */
protected getDisplayText_(): string { protected getDisplayText_(): string {
let text = this.getText(); let text = this.getText();
if (!text) {
// Prevent the field from disappearing if empty.
return Field.NBSP;
}
if (text.length > this.maxDisplayLength) { if (text.length > this.maxDisplayLength) {
// Truncate displayed string and add an ellipsis ('...'). // Truncate displayed string and add an ellipsis ('...').
text = text.substring(0, this.maxDisplayLength - 2) + '…'; text = text.substring(0, this.maxDisplayLength - 2) + '…';
} }
// Replace whitespace with non-breaking spaces so the text doesn't collapse.
text = text.replace(/\s/g, Field.NBSP);
if (this.sourceBlock_ && this.sourceBlock_.RTL) { if (this.sourceBlock_ && this.sourceBlock_.RTL) {
// The SVG is LTR, force text to be RTL by adding an RLM. // The SVG is LTR, force text to be RTL by adding an RLM.
text += '\u200F'; text += '\u200F';
@@ -1057,8 +1024,6 @@ export abstract class Field<T = any>
* rerender this field and adjust for any sizing changes. * rerender this field and adjust for any sizing changes.
* Other fields on the same block will not rerender, because their sizes have * Other fields on the same block will not rerender, because their sizes have
* already been recorded. * already been recorded.
*
* @internal
*/ */
forceRerender() { forceRerender() {
this.isDirty_ = true; this.isDirty_ = true;
@@ -1317,7 +1282,6 @@ export abstract class Field<T = any>
* Subclasses may override this. * Subclasses may override this.
* *
* @returns True if this field has any variable references. * @returns True if this field has any variable references.
* @internal
*/ */
referencesVariables(): boolean { referencesVariables(): boolean {
return false; return false;
@@ -1326,8 +1290,6 @@ export abstract class Field<T = any>
/** /**
* Refresh the variable name referenced by this field if this field references * Refresh the variable name referenced by this field if this field references
* variables. * variables.
*
* @internal
*/ */
refreshVariableName() {} refreshVariableName() {}
// NOP // NOP

View File

@@ -35,11 +35,6 @@ export class FieldCheckbox extends Field<CheckboxBool> {
*/ */
override SERIALIZABLE = true; 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 * NOTE: The default value is set in `Field`, so maintain that value instead
* of overwriting it here or in the constructor. * of overwriting it here or in the constructor.
@@ -114,7 +109,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
super.initView(); super.initView();
const textElement = this.getTextElement(); const textElement = this.getTextElement();
dom.addClass(textElement, 'blocklyCheckbox'); dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField');
textElement.style.display = this.value_ ? 'block' : 'none'; textElement.style.display = this.value_ ? 'block' : 'none';
} }

View File

@@ -23,6 +23,7 @@ import {
} from './field.js'; } from './field.js';
import * as fieldRegistry from './field_registry.js'; import * as fieldRegistry from './field_registry.js';
import {Menu} from './menu.js'; import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js'; import {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
@@ -36,14 +37,10 @@ import {Svg} from './utils/svg.js';
* Class for an editable dropdown field. * Class for an editable dropdown field.
*/ */
export class FieldDropdown extends Field<string> { 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 * Magic constant used to represent a separator in a list of dropdown items.
* height.
*/ */
static MAX_MENU_HEIGHT_VH = 0.45; static readonly SEPARATOR = 'separator';
static ARROW_CHAR = '▾'; static ARROW_CHAR = '▾';
@@ -70,9 +67,6 @@ export class FieldDropdown extends Field<string> {
*/ */
override SERIALIZABLE = true; override SERIALIZABLE = true;
/** Mouse cursor style when over the hotspot that initiates the editor. */
override CURSOR = 'default';
protected menuGenerator_?: MenuGenerator; protected menuGenerator_?: MenuGenerator;
/** A cache of the most recently generated options. */ /** A cache of the most recently generated options. */
@@ -213,6 +207,11 @@ export class FieldDropdown extends Field<string> {
if (this.borderRect_) { if (this.borderRect_) {
dom.addClass(this.borderRect_, 'blocklyDropdownRect'); dom.addClass(this.borderRect_, 'blocklyDropdownRect');
} }
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyField');
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
}
} }
/** /**
@@ -327,7 +326,13 @@ export class FieldDropdown extends Field<string> {
const options = this.getOptions(false); const options = this.getOptions(false);
this.selectedMenuItem = null; this.selectedMenuItem = null;
for (let i = 0; i < options.length; i++) { 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 = (() => { const content = (() => {
if (typeof label === 'object') { if (typeof label === 'object') {
// Convert ImageProperties to an HTMLImageElement. // Convert ImageProperties to an HTMLImageElement.
@@ -541,12 +546,7 @@ export class FieldDropdown extends Field<string> {
height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2, height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,
); );
} else { } else {
arrowWidth = dom.getFastTextWidth( arrowWidth = dom.getTextWidth(this.arrow as SVGTSpanElement);
this.arrow as SVGTSpanElement,
this.getConstants()!.FIELD_TEXT_FONTSIZE,
this.getConstants()!.FIELD_TEXT_FONTWEIGHT,
this.getConstants()!.FIELD_TEXT_FONTFAMILY,
);
} }
this.size_.width = imageWidth + arrowWidth + xPadding * 2; this.size_.width = imageWidth + arrowWidth + xPadding * 2;
this.size_.height = height; this.size_.height = height;
@@ -579,12 +579,7 @@ export class FieldDropdown extends Field<string> {
hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
this.getConstants()!.FIELD_TEXT_HEIGHT, this.getConstants()!.FIELD_TEXT_HEIGHT,
); );
const textWidth = dom.getFastTextWidth( const textWidth = dom.getTextWidth(this.getTextElement());
this.getTextElement(),
this.getConstants()!.FIELD_TEXT_FONTSIZE,
this.getConstants()!.FIELD_TEXT_FONTWEIGHT,
this.getConstants()!.FIELD_TEXT_FONTFAMILY,
);
const xPadding = hasBorder const xPadding = hasBorder
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
: 0; : 0;
@@ -681,7 +676,10 @@ export class FieldDropdown extends Field<string> {
suffix?: string; suffix?: string;
} { } {
let hasImages = false; 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') { if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value]; return [parsing.replaceMessageReferences(label), value];
} }
@@ -762,28 +760,28 @@ export class FieldDropdown extends Field<string> {
} }
let foundError = false; let foundError = false;
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const tuple = options[i]; const option = options[i];
if (!Array.isArray(tuple)) { if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) {
foundError = true; foundError = true;
console.error( console.error(
`Invalid option[${i}]: Each FieldDropdown option must be an array. `Invalid option[${i}]: Each FieldDropdown option must be an array or
Found: ${tuple}`, the string literal 'separator'. Found: ${option}`,
); );
} else if (typeof tuple[1] !== 'string') { } else if (typeof option[1] !== 'string') {
foundError = true; foundError = true;
console.error( console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string. `Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${tuple[1]} in: ${tuple}`, Found ${option[1]} in: ${option}`,
); );
} else if ( } else if (
tuple[0] && option[0] &&
typeof tuple[0] !== 'string' && typeof option[0] !== 'string' &&
typeof tuple[0].src !== 'string' typeof option[0].src !== 'string'
) { ) {
foundError = true; foundError = true;
console.error( console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string `Invalid option[${i}]: Each FieldDropdown option must have a string
label or image description. Found ${tuple[0]} in: ${tuple}`, label or image description. Found ${option[0]} in: ${option}`,
); );
} }
} }
@@ -804,11 +802,12 @@ export interface ImageProperties {
} }
/** /**
* An individual option in the dropdown menu. The first element is the human- * An individual option in the dropdown menu. Can be either the string literal
* readable value (text or image), and the second element is the language- * `separator` for a menu separator item, or an array for normal action menu
* neutral value. * items. In the latter case, the first element is the human-readable value
* (text or image), and the second element is the language-neutral value.
*/ */
export type MenuOption = [string | ImageProperties, string]; export type MenuOption = [string | ImageProperties, string] | 'separator';
/** /**
* A function that generates an array of menu options for FieldDropdown * A function that generates an array of menu options for FieldDropdown

View File

@@ -151,6 +151,10 @@ export class FieldImage extends Field<string> {
this.value_ as string, this.value_ as string,
); );
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyImageField');
}
if (this.clickHandler) { if (this.clickHandler) {
this.imageElement.style.cursor = 'pointer'; this.imageElement.style.cursor = 'pointer';
} }

View File

@@ -100,9 +100,6 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/ */
override SERIALIZABLE = true; override SERIALIZABLE = true;
/** Mouse cursor style when over the hotspot that initiates the editor. */
override CURSOR = 'text';
/** /**
* @param value The initial value of the field. Should cast to a string. * @param value The initial value of the field. Should cast to a string.
* Defaults to an empty string if null or undefined. Also accepts * Defaults to an empty string if null or undefined. Also accepts
@@ -149,6 +146,10 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (this.isFullBlockField()) { if (this.isFullBlockField()) {
this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();
} }
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyInputField');
}
} }
protected override isFullBlockField(): boolean { protected override isFullBlockField(): boolean {
@@ -406,7 +407,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
const clickTarget = this.getClickTarget_(); const clickTarget = this.getClickTarget_();
if (!clickTarget) throw new Error('A click target has not been set.'); 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'); const htmlInput = document.createElement('input');
htmlInput.className = 'blocklyHtmlInput'; htmlInput.className = 'blocklyHtmlInput';
@@ -416,7 +417,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
'spellcheck', 'spellcheck',
this.spellcheck_ as AnyDuringMigration, this.spellcheck_ as AnyDuringMigration,
); );
const scale = this.workspace_!.getScale(); const scale = this.workspace_!.getAbsoluteScale();
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt'; const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt';
div!.style.fontSize = fontSize; div!.style.fontSize = fontSize;
htmlInput.style.fontSize = fontSize; htmlInput.style.fontSize = fontSize;
@@ -501,7 +502,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
const clickTarget = this.getClickTarget_(); const clickTarget = this.getClickTarget_();
if (!clickTarget) throw new Error('A click target has not been set.'); if (!clickTarget) throw new Error('A click target has not been set.');
dom.removeClass(clickTarget, 'editing'); dom.removeClass(clickTarget, 'blocklyEditing');
} }
/** /**

View File

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

View File

@@ -19,6 +19,7 @@ import {
} from './field_input.js'; } from './field_input.js';
import * as fieldRegistry from './field_registry.js'; import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
/** /**
* Class for an editable number field. * Class for an editable number field.
@@ -307,6 +308,19 @@ export class FieldNumber extends FieldInput<number> {
return htmlInput; 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. * Construct a FieldNumber from a JSON arg object.
* *

View File

@@ -21,6 +21,7 @@ import {
FieldInputValidator, FieldInputValidator,
} from './field_input.js'; } from './field_input.js';
import * as fieldRegistry from './field_registry.js'; import * as fieldRegistry from './field_registry.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js'; import * as parsing from './utils/parsing.js';
/** /**
@@ -49,6 +50,13 @@ export class FieldTextInput extends FieldInput<string> {
super(value, validator, config); 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. * Ensure that the input value casts to a valid string.
* *

View File

@@ -23,14 +23,16 @@ import {
MenuOption, MenuOption,
} from './field_dropdown.js'; } from './field_dropdown.js';
import * as fieldRegistry from './field_registry.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 * as internalConstants from './internal_constants.js';
import type {Menu} from './menu.js'; import type {Menu} from './menu.js';
import type {MenuItem} from './menuitem.js'; import type {MenuItem} from './menuitem.js';
import {Msg} from './msg.js'; import {Msg} from './msg.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js'; import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js'; import {Size} from './utils/size.js';
import {VariableModel} from './variable_model.js';
import * as Variables from './variables.js'; import * as Variables from './variables.js';
import {WorkspaceSvg} from './workspace_svg.js';
import * as Xml from './xml.js'; import * as Xml from './xml.js';
/** /**
@@ -51,7 +53,7 @@ export class FieldVariable extends FieldDropdown {
protected override size_: Size; protected override size_: Size;
/** The variable model associated with this field. */ /** 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 * Serializable fields are saved by the serializer, non-serializable fields
@@ -148,6 +150,11 @@ export class FieldVariable extends FieldDropdown {
this.doValueUpdate_(variable.getId()); this.doValueUpdate_(variable.getId());
} }
override initView() {
super.initView();
dom.addClass(this.fieldGroup_!, 'blocklyVariableField');
}
override shouldAddBorderRect_() { override shouldAddBorderRect_() {
const block = this.getSourceBlock(); const block = this.getSourceBlock();
if (!block) { if (!block) {
@@ -190,12 +197,12 @@ export class FieldVariable extends FieldDropdown {
); );
// This should never happen :) // This should never happen :)
if (variableType !== null && variableType !== variable.type) { if (variableType !== null && variableType !== variable.getType()) {
throw Error( throw Error(
"Serialized variable type with id '" + "Serialized variable type with id '" +
variable.getId() + variable.getId() +
"' had type " + "' had type " +
variable.type + variable.getType() +
', and ' + ', and ' +
'does not match variable field that references it: ' + 'does not match variable field that references it: ' +
Xml.domToText(fieldElement) + Xml.domToText(fieldElement) +
@@ -218,9 +225,9 @@ export class FieldVariable extends FieldDropdown {
this.initModel(); this.initModel();
fieldElement.id = this.variable!.getId(); fieldElement.id = this.variable!.getId();
fieldElement.textContent = this.variable!.name; fieldElement.textContent = this.variable!.getName();
if (this.variable!.type) { if (this.variable!.getType()) {
fieldElement.setAttribute('variabletype', this.variable!.type); fieldElement.setAttribute('variabletype', this.variable!.getType());
} }
return fieldElement; return fieldElement;
} }
@@ -243,8 +250,8 @@ export class FieldVariable extends FieldDropdown {
this.initModel(); this.initModel();
const state = {'id': this.variable!.getId()}; const state = {'id': this.variable!.getId()};
if (doFullSerialization) { if (doFullSerialization) {
(state as AnyDuringMigration)['name'] = this.variable!.name; (state as AnyDuringMigration)['name'] = this.variable!.getName();
(state as AnyDuringMigration)['type'] = this.variable!.type; (state as AnyDuringMigration)['type'] = this.variable!.getType();
} }
return state; return state;
} }
@@ -301,7 +308,7 @@ export class FieldVariable extends FieldDropdown {
* is selected. * is selected.
*/ */
override getText(): string { override getText(): string {
return this.variable ? this.variable.name : ''; return this.variable ? this.variable.getName() : '';
} }
/** /**
@@ -312,10 +319,19 @@ export class FieldVariable extends FieldDropdown {
* @returns The selected variable, or null if none was selected. * @returns The selected variable, or null if none was selected.
* @internal * @internal
*/ */
getVariable(): VariableModel | null { getVariable(): IVariableModel<IVariableState> | null {
return this.variable; 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. * Gets the validation function for this field, or null if not set.
* Returns null if the variable is not set, because validators should not * Returns null if the variable is not set, because validators should not
@@ -359,7 +375,7 @@ export class FieldVariable extends FieldDropdown {
return null; return null;
} }
// Type Checks. // Type Checks.
const type = variable.type; const type = variable.getType();
if (!this.typeIsAllowed(type)) { if (!this.typeIsAllowed(type)) {
console.warn("Variable type doesn't match this field! Type was " + type); console.warn("Variable type doesn't match this field! Type was " + type);
return null; return null;
@@ -414,7 +430,7 @@ export class FieldVariable extends FieldDropdown {
if (variableTypes === null) { if (variableTypes === null) {
// If variableTypes is null, return all variable types. // If variableTypes is null, return all variable types.
if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) {
return this.sourceBlock_.workspace.getVariableTypes(); return this.sourceBlock_.workspace.getVariableMap().getTypes();
} }
} }
variableTypes = variableTypes || ['']; variableTypes = variableTypes || [''];
@@ -493,16 +509,17 @@ export class FieldVariable extends FieldDropdown {
const id = menuItem.getValue(); const id = menuItem.getValue();
// Handle special cases. // Handle special cases.
if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) {
if (id === internalConstants.RENAME_VARIABLE_ID) { if (id === internalConstants.RENAME_VARIABLE_ID && this.variable) {
// Rename variable. // Rename variable.
Variables.renameVariable( Variables.renameVariable(this.sourceBlock_.workspace, this.variable);
this.sourceBlock_.workspace,
this.variable as VariableModel,
);
return; return;
} else if (id === internalConstants.DELETE_VARIABLE_ID) { } else if (id === internalConstants.DELETE_VARIABLE_ID && this.variable) {
// Delete variable. // Delete variable.
this.sourceBlock_.workspace.deleteVariableById(this.variable!.getId()); const workspace = this.variable.getWorkspace();
Variables.deleteVariable(workspace, this.variable, this.sourceBlock_);
if (workspace instanceof WorkspaceSvg) {
workspace.refreshToolboxSelection();
}
return; return;
} }
} }
@@ -554,24 +571,35 @@ export class FieldVariable extends FieldDropdown {
); );
} }
const name = this.getText(); const name = this.getText();
let variableModelList: VariableModel[] = []; let variableModelList: IVariableModel<IVariableState>[] = [];
if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { const sourceBlock = this.getSourceBlock();
if (sourceBlock && !sourceBlock.isDeadOrDying()) {
const workspace = sourceBlock.workspace;
const variableTypes = this.getVariableTypes(); const variableTypes = this.getVariableTypes();
// Get a copy of the list, so that adding rename and new variable options // Get a copy of the list, so that adding rename and new variable options
// doesn't modify the workspace's list. // doesn't modify the workspace's list.
for (let i = 0; i < variableTypes.length; i++) { for (let i = 0; i < variableTypes.length; i++) {
const variableType = variableTypes[i]; const variableType = variableTypes[i];
const variables = const variables = workspace.getVariablesOfType(variableType);
this.sourceBlock_.workspace.getVariablesOfType(variableType);
variableModelList = variableModelList.concat(variables); 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][] = []; const options: [string, string][] = [];
for (let i = 0; i < variableModelList.length; i++) { for (let i = 0; i < variableModelList.length; i++) {
// Set the UUID as the internal representation of the variable. // 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([ options.push([
Msg['RENAME_VARIABLE'], Msg['RENAME_VARIABLE'],

View File

@@ -11,46 +11,32 @@
*/ */
// Former goog.module ID: Blockly.Flyout // Former goog.module ID: Blockly.Flyout
import type {Block} from './block.js';
import {BlockSvg} from './block_svg.js'; import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js'; import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import {ComponentManager} from './component_manager.js'; import {ComponentManager} from './component_manager.js';
import {MANUALLY_DISABLED} from './constants.js';
import {DeleteArea} from './delete_area.js'; import {DeleteArea} from './delete_area.js';
import type {Abstract as AbstractEvent} from './events/events_abstract.js'; import type {Abstract as AbstractEvent} from './events/events_abstract.js';
import {EventType} from './events/type.js'; import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.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 {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js';
import {IAutoHideable} from './interfaces/i_autohideable.js'; import {IAutoHideable} from './interfaces/i_autohideable.js';
import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import type {Options} from './options.js'; import type {Options} from './options.js';
import * as registry from './registry.js';
import * as renderManagement from './render_management.js'; import * as renderManagement from './render_management.js';
import {ScrollbarPair} from './scrollbar_pair.js'; import {ScrollbarPair} from './scrollbar_pair.js';
import {SEPARATOR_TYPE} from './separator_flyout_inflater.js';
import * as blocks from './serialization/blocks.js'; import * as blocks from './serialization/blocks.js';
import * as Tooltip from './tooltip.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js'; import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js'; import * as idGenerator from './utils/idgenerator.js';
import {Svg} from './utils/svg.js'; import {Svg} from './utils/svg.js';
import * as toolbox from './utils/toolbox.js'; import * as toolbox from './utils/toolbox.js';
import * as utilsXml from './utils/xml.js';
import * as Variables from './variables.js'; import * as Variables from './variables.js';
import {WorkspaceSvg} from './workspace_svg.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. * Class for a flyout.
@@ -85,12 +71,11 @@ export abstract class Flyout
protected abstract setMetrics_(xyRatio: {x?: number; y?: number}): void; 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 contents The flyout elements to lay out.
* @param gaps The visible gaps between blocks.
*/ */
protected abstract layout_(contents: FlyoutItem[], gaps: number[]): void; protected abstract layout_(contents: FlyoutItem[]): void;
/** /**
* Scroll the flyout. * Scroll the flyout.
@@ -100,8 +85,8 @@ export abstract class Flyout
protected abstract wheel_(e: WheelEvent): void; protected abstract wheel_(e: WheelEvent): void;
/** /**
* Compute height of flyout. Position mat under each block. * Compute bounds of flyout.
* For RTL: Lay out the blocks right-aligned. * For RTL: Lay out the elements right-aligned.
*/ */
protected abstract reflowInternal_(): void; protected abstract reflowInternal_(): void;
@@ -124,11 +109,6 @@ export abstract class Flyout
*/ */
abstract scrollToStart(): void; abstract scrollToStart(): void;
/**
* The type of a flyout content item.
*/
static FlyoutItemType = FlyoutItemType;
protected workspace_: WorkspaceSvg; protected workspace_: WorkspaceSvg;
RTL: boolean; RTL: boolean;
/** /**
@@ -148,43 +128,15 @@ export abstract class Flyout
/** /**
* Function that will be registered as a change listener on the workspace * 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; private reflowWrapper: ((e: AbstractEvent) => void) | null = null;
/** /**
* Function that disables blocks in the flyout based on max block counts * List of flyout elements.
* 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.
*/ */
protected contents: FlyoutItem[] = []; 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; protected readonly tabWidth_: number;
/** /**
@@ -194,11 +146,6 @@ export abstract class Flyout
*/ */
targetWorkspace!: WorkspaceSvg; targetWorkspace!: WorkspaceSvg;
/**
* A list of blocks that can be reused.
*/
private recycledBlocks: BlockSvg[] = [];
/** /**
* Does the flyout automatically close when a block is created? * Does the flyout automatically close when a block is created?
*/ */
@@ -213,7 +160,6 @@ export abstract class Flyout
* Whether the workspace containing this flyout is visible. * Whether the workspace containing this flyout is visible.
*/ */
private containerVisible = true; private containerVisible = true;
protected rectMap_: WeakMap<BlockSvg, SVGElement>;
/** /**
* Corner radius of the flyout background. * Corner radius of the flyout background.
@@ -271,6 +217,13 @@ export abstract class Flyout
* The root SVG group for the button or label. * The root SVG group for the button or label.
*/ */
protected svgGroup_: SVGGElement | null = null; 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 * @param workspaceOptions Dictionary of options for the
* workspace. * workspace.
@@ -310,15 +263,7 @@ export abstract class Flyout
this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH; this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH;
/** /**
* A map from blocks to the rects which are beneath them to act as input * Margin around the edges of the elements in the flyout.
* targets.
*
* @internal
*/
this.rectMap_ = new WeakMap();
/**
* Margin around the edges of the blocks in the flyout.
*/ */
this.MARGIN = this.CORNER_RADIUS; this.MARGIN = this.CORNER_RADIUS;
@@ -403,8 +348,6 @@ export abstract class Flyout
this.wheel_, this.wheel_,
), ),
); );
this.filterWrapper = this.filterForCapacity.bind(this);
this.targetWorkspace.addChangeListener(this.filterWrapper);
// Dragging the flyout up and down. // Dragging the flyout up and down.
this.boundEvents.push( this.boundEvents.push(
@@ -448,9 +391,6 @@ export abstract class Flyout
browserEvents.unbind(event); browserEvents.unbind(event);
} }
this.boundEvents.length = 0; this.boundEvents.length = 0;
if (this.filterWrapper) {
this.targetWorkspace.removeChangeListener(this.filterWrapper);
}
if (this.workspace_) { if (this.workspace_) {
this.workspace_.getThemeManager().unsubscribe(this.svgBackground_!); this.workspace_.getThemeManager().unsubscribe(this.svgBackground_!);
this.workspace_.dispose(); this.workspace_.dispose();
@@ -570,16 +510,16 @@ export abstract class Flyout
} }
/** /**
* Get the list of buttons and blocks of the current flyout. * Get the list of elements of the current flyout.
* *
* @returns The array of flyout buttons and blocks. * @returns The array of flyout elements.
*/ */
getContents(): FlyoutItem[] { getContents(): FlyoutItem[] {
return this.contents; 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. * @param contents - The array of items for the flyout.
*/ */
@@ -654,16 +594,11 @@ export abstract class Flyout
return; return;
} }
this.setVisible(false); this.setVisible(false);
// Delete all the event listeners.
for (const listen of this.listeners) {
browserEvents.unbind(listen);
}
this.listeners.length = 0;
if (this.reflowWrapper) { if (this.reflowWrapper) {
this.workspace_.removeChangeListener(this.reflowWrapper); this.workspace_.removeChangeListener(this.reflowWrapper);
this.reflowWrapper = null; 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/ // https://neil.fraser.name/news/2014/08/09/
} }
@@ -691,26 +626,30 @@ export abstract class Flyout
renderManagement.triggerQueuedRenders(this.workspace_); renderManagement.triggerQueuedRenders(this.workspace_);
this.setContents(flyoutInfo.contents); this.setContents(flyoutInfo);
this.layout_(flyoutInfo.contents, flyoutInfo.gaps); this.layout_(flyoutInfo);
if (this.horizontalLayout) { if (this.horizontalLayout) {
this.height_ = 0; this.height_ = 0;
} else { } else {
this.width_ = 0; this.width_ = 0;
} }
this.workspace_.setResizesEnabled(true);
this.reflow(); this.reflow();
this.workspace_.setResizesEnabled(true);
this.filterForCapacity(); // Listen for block change events, and reflow the flyout in response. This
// accommodates e.g. resizing a non-autoclosing flyout in response to the
// Correctly position the flyout's scrollbar when it opens. // user typing long strings into fields on the blocks in the flyout.
this.position(); this.reflowWrapper = (event) => {
if (
this.reflowWrapper = this.reflow.bind(this); event.type === EventType.BLOCK_CHANGE ||
event.type === EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE
) {
this.reflow();
}
};
this.workspace_.addChangeListener(this.reflowWrapper); this.workspace_.addChangeListener(this.reflowWrapper);
this.emptyRecycledBlocks();
} }
/** /**
@@ -719,15 +658,12 @@ export abstract class Flyout
* *
* @param parsedContent The array * @param parsedContent The array
* of objects to show in the flyout. * 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): { private createFlyoutInfo(
contents: FlyoutItem[]; parsedContent: toolbox.FlyoutItemInfoArray,
gaps: number[]; ): FlyoutItem[] {
} {
const contents: FlyoutItem[] = []; const contents: FlyoutItem[] = [];
const gaps: number[] = [];
this.permanentlyDisabled.length = 0;
const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y; const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y;
for (const info of parsedContent) { for (const info of parsedContent) {
if ('custom' in info) { if ('custom' in info) {
@@ -736,44 +672,60 @@ export abstract class Flyout
const flyoutDef = this.getDynamicCategoryContents(categoryName); const flyoutDef = this.getDynamicCategoryContents(categoryName);
const parsedDynamicContent = const parsedDynamicContent =
toolbox.convertFlyoutDefToJsonArray(flyoutDef); toolbox.convertFlyoutDefToJsonArray(flyoutDef);
const {contents: dynamicContents, gaps: dynamicGaps} = contents.push(...this.createFlyoutInfo(parsedDynamicContent));
this.createFlyoutInfo(parsedDynamicContent);
contents.push(...dynamicContents);
gaps.push(...dynamicGaps);
} }
switch (info['kind'].toUpperCase()) { const type = info['kind'].toLowerCase();
case 'BLOCK': { const inflater = this.getInflaterForType(type);
const blockInfo = info as toolbox.BlockInfo; if (inflater) {
const block = this.createFlyoutBlock(blockInfo); contents.push(inflater.load(info, this));
contents.push({type: FlyoutItemType.BLOCK, block: block}); const gap = inflater.gapForItem(info, defaultGap);
this.addBlockGap(blockInfo, gaps, defaultGap); if (gap) {
break; contents.push(
} new FlyoutItem(
case 'SEP': { new FlyoutSeparator(
const sepInfo = info as toolbox.SeparatorInfo; gap,
this.addSeparatorGap(sepInfo, gaps, defaultGap); this.horizontalLayout ? SeparatorAxis.X : SeparatorAxis.Y,
break; ),
} SEPARATOR_TYPE,
case 'LABEL': { false,
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;
} }
} }
} }
return {contents: contents, gaps: gaps}; return this.normalizeSeparators(contents);
}
/**
* Updates and returns the provided list of flyout contents to flatten
* separators as needed.
*
* When multiple separators occur one after another, the value of the last one
* takes precedence and the earlier separators in the group are removed.
*
* @param contents The list of flyout contents to flatten separators in.
* @returns An updated list of flyout contents with only one separator between
* each non-separator item.
*/
protected normalizeSeparators(contents: FlyoutItem[]): FlyoutItem[] {
for (let i = contents.length - 1; i > 0; i--) {
const elementType = contents[i].getType().toLowerCase();
const previousElementType = contents[i - 1].getType().toLowerCase();
if (
elementType === SEPARATOR_TYPE &&
previousElementType === SEPARATOR_TYPE
) {
// Remove previousElement from the array, shifting the current element
// forward as a result. This preserves the behavior where explicit
// separator elements override the value of prior implicit (or explicit)
// separator elements.
contents.splice(i - 1, 1);
}
}
return contents;
} }
/** /**
@@ -800,287 +752,18 @@ export abstract class Flyout
} }
/** /**
* Creates a flyout button or a flyout label. * Delete elements from a previous showing of the flyout.
*
* @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.
*/ */
private clearOldBlocks() { private clearOldBlocks() {
// Delete any blocks from a previous showing. this.getContents().forEach((item) => {
const oldBlocks = this.workspace_.getTopBlocks(false); const inflater = this.getInflaterForType(item.getType());
for (let i = 0, block; (block = oldBlocks[i]); i++) { inflater?.disposeItem(item);
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;
// Clear potential variables from the previous showing. // Clear potential variables from the previous showing.
this.workspace_.getPotentialVariableMap()?.clear(); 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. * Pointer down on the flyout background. Start a vertical scroll drag.
* *
@@ -1103,7 +786,7 @@ export abstract class Flyout
* @internal * @internal
*/ */
isBlockCreatable(block: BlockSvg): boolean { isBlockCreatable(block: BlockSvg): boolean {
return block.isEnabled(); return block.isEnabled() && !this.getTargetWorkspace().isReadOnly();
} }
/** /**
@@ -1149,123 +832,12 @@ export abstract class Flyout
} }
if (this.autoClose) { if (this.autoClose) {
this.hide(); this.hide();
} else {
this.filterForCapacity();
} }
return newBlock; return newBlock;
} }
/** /**
* Initialize the given button: move it to the correct location, * Reflow flyout contents.
* 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() { reflow() {
if (this.reflowWrapper) { if (this.reflowWrapper) {
@@ -1364,13 +936,29 @@ export abstract class Flyout
// No 'reason' provided since events are disabled. // No 'reason' provided since events are disabled.
block.moveTo(new Coordinate(finalOffset.x, finalOffset.y)); block.moveTo(new Coordinate(finalOffset.x, finalOffset.y));
} }
}
/** /**
* A flyout content item. * Returns the inflater responsible for constructing items of the given type.
*/ *
export interface FlyoutItem { * @param type The type of flyout content item to provide an inflater for.
type: FlyoutItemType; * @returns An inflater object for the given type, or null if no inflater
button?: FlyoutButton | undefined; * is registered for that type.
block?: BlockSvg | undefined; */
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;
}
} }

View File

@@ -14,9 +14,12 @@
import type {IASTNodeLocationSvg} from './blockly.js'; import type {IASTNodeLocationSvg} from './blockly.js';
import * as browserEvents from './browser_events.js'; import * as browserEvents from './browser_events.js';
import * as Css from './css.js'; import * as Css from './css.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IRenderedElement} from './interfaces/i_rendered_element.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js'; import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js'; import * as parsing from './utils/parsing.js';
import {Rect} from './utils/rect.js';
import * as style from './utils/style.js'; import * as style from './utils/style.js';
import {Svg} from './utils/svg.js'; import {Svg} from './utils/svg.js';
import type * as toolbox from './utils/toolbox.js'; import type * as toolbox from './utils/toolbox.js';
@@ -25,7 +28,9 @@ import type {WorkspaceSvg} from './workspace_svg.js';
/** /**
* Class for a button or label in the flyout. * Class for a button or label in the flyout.
*/ */
export class FlyoutButton implements IASTNodeLocationSvg { export class FlyoutButton
implements IASTNodeLocationSvg, IBoundedElement, IRenderedElement
{
/** The horizontal margin around the text in the button. */ /** The horizontal margin around the text in the button. */
static TEXT_MARGIN_X = 5; static TEXT_MARGIN_X = 5;
@@ -41,7 +46,8 @@ export class FlyoutButton implements IASTNodeLocationSvg {
private readonly cssClass: string | null; private readonly cssClass: string | null;
/** Mouse up event data. */ /** Mouse up event data. */
private onMouseUpWrapper: browserEvents.Data | null = null; private onMouseDownWrapper: browserEvents.Data;
private onMouseUpWrapper: browserEvents.Data;
info: toolbox.ButtonOrLabelInfo; info: toolbox.ButtonOrLabelInfo;
/** The width of the button's rect. */ /** The width of the button's rect. */
@@ -51,7 +57,7 @@ export class FlyoutButton implements IASTNodeLocationSvg {
height = 0; height = 0;
/** The root SVG group for the button or label. */ /** 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. */ /** The SVG element with the text of the label or button. */
private svgText: SVGTextElement | null = null; private svgText: SVGTextElement | null = null;
@@ -92,14 +98,6 @@ export class FlyoutButton implements IASTNodeLocationSvg {
/** The JSON specifying the label / button. */ /** The JSON specifying the label / button. */
this.info = json; this.info = json;
}
/**
* Create the button elements.
*
* @returns The button's SVG group.
*/
createDom(): SVGElement {
let cssClass = this.isFlyoutLabel let cssClass = this.isFlyoutLabel
? 'blocklyFlyoutLabel' ? 'blocklyFlyoutLabel'
: 'blocklyFlyoutButton'; : 'blocklyFlyoutButton';
@@ -179,7 +177,7 @@ export class FlyoutButton implements IASTNodeLocationSvg {
fontWeight, fontWeight,
fontFamily, fontFamily,
); );
this.height = fontMetrics.height; this.height = this.height || fontMetrics.height;
if (!this.isFlyoutLabel) { if (!this.isFlyoutLabel) {
this.width += 2 * FlyoutButton.TEXT_MARGIN_X; this.width += 2 * FlyoutButton.TEXT_MARGIN_X;
@@ -198,15 +196,24 @@ export class FlyoutButton implements IASTNodeLocationSvg {
this.updateTransform(); this.updateTransform();
// AnyDuringMigration because: Argument of type 'SVGGElement | null' is not this.onMouseDownWrapper = browserEvents.conditionalBind(
// assignable to parameter of type 'EventTarget'. this.svgGroup,
'pointerdown',
this,
this.onMouseDown,
);
this.onMouseUpWrapper = browserEvents.conditionalBind( this.onMouseUpWrapper = browserEvents.conditionalBind(
this.svgGroup as AnyDuringMigration, this.svgGroup,
'pointerup', 'pointerup',
this, this,
this.onMouseUp, 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. */ /** Correctly position the flyout button and make it visible. */
@@ -235,6 +242,17 @@ export class FlyoutButton implements IASTNodeLocationSvg {
this.updateTransform(); 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. */ /** @returns Whether or not the button is a label. */
isLabel(): boolean { isLabel(): boolean {
return this.isFlyoutLabel; return this.isFlyoutLabel;
@@ -250,6 +268,21 @@ export class FlyoutButton implements IASTNodeLocationSvg {
return this.position; 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. */ /** @returns Text of the button. */
getButtonText(): string { getButtonText(): string {
return this.text; return this.text;
@@ -275,9 +308,8 @@ export class FlyoutButton implements IASTNodeLocationSvg {
/** Dispose of this button. */ /** Dispose of this button. */
dispose() { dispose() {
if (this.onMouseUpWrapper) { browserEvents.unbind(this.onMouseDownWrapper);
browserEvents.unbind(this.onMouseUpWrapper); browserEvents.unbind(this.onMouseUpWrapper);
}
if (this.svgGroup) { if (this.svgGroup) {
dom.removeNode(this.svgGroup); dom.removeNode(this.svgGroup);
} }
@@ -342,6 +374,21 @@ export class FlyoutButton implements IASTNodeLocationSvg {
} }
} }
} }
private onMouseDown(e: PointerEvent) {
const gesture = this.targetWorkspace.getGesture(e);
const flyout = this.targetWorkspace.getFlyout();
if (gesture && flyout) {
gesture.handleFlyoutStart(e, flyout);
}
}
/**
* @returns The root SVG element of this rendered element.
*/
getSvgRoot() {
return this.svgGroup;
}
} }
/** CSS for buttons and labels. See css.js for use. */ /** CSS for buttons and labels. See css.js for use. */

View File

@@ -13,8 +13,8 @@
import * as browserEvents from './browser_events.js'; import * as browserEvents from './browser_events.js';
import * as dropDownDiv from './dropdowndiv.js'; import * as dropDownDiv from './dropdowndiv.js';
import {Flyout, FlyoutItem} from './flyout_base.js'; import {Flyout} from './flyout_base.js';
import type {FlyoutButton} from './flyout_button.js'; import type {FlyoutItem} from './flyout_item.js';
import type {Options} from './options.js'; import type {Options} from './options.js';
import * as registry from './registry.js'; import * as registry from './registry.js';
import {Scrollbar} from './scrollbar.js'; import {Scrollbar} from './scrollbar.js';
@@ -98,7 +98,7 @@ export class HorizontalFlyout extends Flyout {
if (atTop) { if (atTop) {
y = toolboxMetrics.height; y = toolboxMetrics.height;
} else { } else {
y = viewMetrics.height - this.height_; y = viewMetrics.height - this.getHeight();
} }
} else { } else {
if (atTop) { 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 // 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 // blocklyDiv, we calculate the full height of the div minus the height
// of the flyout. // 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; this.width_ = targetWorkspaceViewMetrics.width;
const edgeWidth = targetWorkspaceViewMetrics.width - 2 * this.CORNER_RADIUS; 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); this.setBackgroundPath(edgeWidth, edgeHeight);
const x = this.getX(); const x = this.getX();
const y = this.getY(); 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. * Lay out the blocks in the flyout.
* *
* @param contents The blocks and buttons to lay out. * @param contents The flyout items to lay out.
* @param gaps The visible gaps between blocks.
*/ */
protected override layout_(contents: FlyoutItem[], gaps: number[]) { protected override layout_(contents: FlyoutItem[]) {
this.workspace_.scale = this.targetWorkspace!.scale; this.workspace_.scale = this.targetWorkspace!.scale;
const margin = this.MARGIN; const margin = this.MARGIN;
let cursorX = margin + this.tabWidth_; let cursorX = margin + this.tabWidth_;
@@ -264,43 +263,11 @@ export class HorizontalFlyout extends Flyout {
contents = contents.reverse(); contents = contents.reverse();
} }
for (let i = 0, item; (item = contents[i]); i++) { for (const item of contents) {
if (item.type === 'block') { const rect = item.getElement().getBoundingRectangle();
const block = item.block; const moveX = this.RTL ? cursorX + rect.getWidth() : cursorX;
item.getElement().moveBy(moveX, cursorY);
if (block === undefined || block === null) { cursorX += item.getElement().getBoundingRectangle().getWidth();
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];
}
} }
} }
@@ -367,26 +334,17 @@ export class HorizontalFlyout extends Flyout {
*/ */
protected override reflowInternal_() { protected override reflowInternal_() {
this.workspace_.scale = this.getFlyoutScale(); this.workspace_.scale = this.getFlyoutScale();
let flyoutHeight = 0; let flyoutHeight = this.getContents().reduce((maxHeightSoFar, item) => {
const blocks = this.workspace_.getTopBlocks(false); return Math.max(
for (let i = 0, block; (block = blocks[i]); i++) { maxHeightSoFar,
flyoutHeight = Math.max(flyoutHeight, block.getHeightWidth().height); item.getElement().getBoundingRectangle().getHeight(),
} );
const buttons = this.buttons_; }, 0);
for (let i = 0, button; (button = buttons[i]); i++) {
flyoutHeight = Math.max(flyoutHeight, button.height);
}
flyoutHeight += this.MARGIN * 1.5; flyoutHeight += this.MARGIN * 1.5;
flyoutHeight *= this.workspace_.scale; flyoutHeight *= this.workspace_.scale;
flyoutHeight += Scrollbar.scrollbarThickness; flyoutHeight += Scrollbar.scrollbarThickness;
if (this.height_ !== flyoutHeight) { if (this.getHeight() !== flyoutHeight) {
for (let i = 0, block; (block = blocks[i]); i++) {
if (this.rectMap_.has(block)) {
this.moveRectToBlock_(this.rectMap_.get(block)!, block);
}
}
// TODO(#7689): Remove this. // TODO(#7689): Remove this.
// Workspace with no scrollbars where this is permanently open on the top. // Workspace with no scrollbars where this is permanently open on the top.
// If scrollbars exist they properly update the metrics. // If scrollbars exist they properly update the metrics.

42
core/flyout_item.ts Normal file
View File

@@ -0,0 +1,42 @@
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
/**
* Representation of an item displayed in a flyout.
*/
export class FlyoutItem {
/**
* Creates a new FlyoutItem.
*
* @param element The element that will be displayed in the flyout.
* @param type The type of element. Should correspond to the type of the
* flyout inflater that created this object.
* @param focusable True if the element should be allowed to be focused by
* e.g. keyboard navigation in the flyout.
*/
constructor(
private element: IBoundedElement,
private type: string,
private focusable: boolean,
) {}
/**
* Returns the element displayed in the flyout.
*/
getElement() {
return this.element;
}
/**
* Returns the type of flyout element this item represents.
*/
getType() {
return this.type;
}
/**
* Returns whether or not the flyout element can receive focus.
*/
isFocusable() {
return this.focusable;
}
}

61
core/flyout_separator.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import {Rect} from './utils/rect.js';
/**
* Representation of a gap between elements in a flyout.
*/
export class FlyoutSeparator implements IBoundedElement {
private x = 0;
private y = 0;
/**
* Creates a new separator.
*
* @param gap The amount of space this separator should occupy.
* @param axis The axis along which this separator occupies space.
*/
constructor(
private gap: number,
private axis: SeparatorAxis,
) {}
/**
* Returns the bounding box of this separator.
*
* @returns The bounding box of this separator.
*/
getBoundingRectangle(): Rect {
switch (this.axis) {
case SeparatorAxis.X:
return new Rect(this.y, this.y, this.x, this.x + this.gap);
case SeparatorAxis.Y:
return new Rect(this.y, this.y + this.gap, this.x, this.x);
}
}
/**
* Repositions this separator.
*
* @param dx The distance to move this separator on the X axis.
* @param dy The distance to move this separator on the Y axis.
* @param _reason The reason this move was initiated.
*/
moveBy(dx: number, dy: number, _reason?: string[]) {
this.x += dx;
this.y += dy;
}
}
/**
* Representation of an axis along which a separator occupies space.
*/
export const enum SeparatorAxis {
X = 'x',
Y = 'y',
}

View File

@@ -13,8 +13,8 @@
import * as browserEvents from './browser_events.js'; import * as browserEvents from './browser_events.js';
import * as dropDownDiv from './dropdowndiv.js'; import * as dropDownDiv from './dropdowndiv.js';
import {Flyout, FlyoutItem} from './flyout_base.js'; import {Flyout} from './flyout_base.js';
import type {FlyoutButton} from './flyout_button.js'; import type {FlyoutItem} from './flyout_item.js';
import type {Options} from './options.js'; import type {Options} from './options.js';
import * as registry from './registry.js'; import * as registry from './registry.js';
import {Scrollbar} from './scrollbar.js'; import {Scrollbar} from './scrollbar.js';
@@ -86,7 +86,7 @@ export class VerticalFlyout extends Flyout {
if (this.toolboxPosition_ === toolbox.Position.LEFT) { if (this.toolboxPosition_ === toolbox.Position.LEFT) {
x = toolboxMetrics.width; x = toolboxMetrics.width;
} else { } else {
x = viewMetrics.width - this.width_; x = viewMetrics.width - this.getWidth();
} }
} else { } else {
if (this.toolboxPosition_ === toolbox.Position.LEFT) { 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 // 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 // blocklyDiv, we calculate the full width of the div minus the width
// of the flyout. // 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(); const targetWorkspaceViewMetrics = metricsManager.getViewMetrics();
this.height_ = targetWorkspaceViewMetrics.height; this.height_ = targetWorkspaceViewMetrics.height;
const edgeWidth = this.width_ - this.CORNER_RADIUS; const edgeWidth = this.getWidth() - this.CORNER_RADIUS;
const edgeHeight = const edgeHeight =
targetWorkspaceViewMetrics.height - 2 * this.CORNER_RADIUS; targetWorkspaceViewMetrics.height - 2 * this.CORNER_RADIUS;
this.setBackgroundPath(edgeWidth, edgeHeight); this.setBackgroundPath(edgeWidth, edgeHeight);
@@ -138,7 +138,7 @@ export class VerticalFlyout extends Flyout {
const x = this.getX(); const x = this.getX();
const y = this.getY(); 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. * Lay out the blocks in the flyout.
* *
* @param contents The blocks and buttons to lay out. * @param contents The flyout items to lay out.
* @param gaps The visible gaps between blocks.
*/ */
protected override layout_(contents: FlyoutItem[], gaps: number[]) { protected override layout_(contents: FlyoutItem[]) {
this.workspace_.scale = this.targetWorkspace!.scale; this.workspace_.scale = this.targetWorkspace!.scale;
const margin = this.MARGIN; const margin = this.MARGIN;
const cursorX = this.RTL ? margin : margin + this.tabWidth_; const cursorX = this.RTL ? margin : margin + this.tabWidth_;
let cursorY = margin; let cursorY = margin;
for (let i = 0, item; (item = contents[i]); i++) { for (const item of contents) {
if (item.type === 'block') { item.getElement().moveBy(cursorX, cursorY);
const block = item.block; cursorY += item.getElement().getBoundingRectangle().getHeight();
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];
}
} }
} }
@@ -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. * For RTL: Lay out the blocks and buttons to be right-aligned.
*/ */
protected override reflowInternal_() { protected override reflowInternal_() {
this.workspace_.scale = this.getFlyoutScale(); this.workspace_.scale = this.getFlyoutScale();
let flyoutWidth = 0; let flyoutWidth = this.getContents().reduce((maxWidthSoFar, item) => {
const blocks = this.workspace_.getTopBlocks(false); return Math.max(
for (let i = 0, block; (block = blocks[i]); i++) { maxWidthSoFar,
let width = block.getHeightWidth().width; item.getElement().getBoundingRectangle().getWidth(),
if (block.outputConnection) { );
width -= this.tabWidth_; }, 0);
}
flyoutWidth = Math.max(flyoutWidth, width);
}
for (let i = 0, button; (button = this.buttons_[i]); i++) {
flyoutWidth = Math.max(flyoutWidth, button.width);
}
flyoutWidth += this.MARGIN * 1.5 + this.tabWidth_; flyoutWidth += this.MARGIN * 1.5 + this.tabWidth_;
flyoutWidth *= this.workspace_.scale; flyoutWidth *= this.workspace_.scale;
flyoutWidth += Scrollbar.scrollbarThickness; flyoutWidth += Scrollbar.scrollbarThickness;
if (this.width_ !== flyoutWidth) { if (this.getWidth() !== 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.RTL) { if (this.RTL) {
// With the flyoutWidth known, right-align the buttons. // With the flyoutWidth known, right-align the flyout contents.
for (let i = 0, button; (button = this.buttons_[i]); i++) { for (const item of this.getContents()) {
const y = button.getPosition().y; const oldX = item.getElement().getBoundingRectangle().left;
const x = const newX =
flyoutWidth / this.workspace_.scale - flyoutWidth / this.workspace_.scale -
button.width - item.getElement().getBoundingRectangle().getWidth() -
this.MARGIN - this.MARGIN -
this.tabWidth_; this.tabWidth_;
button.moveTo(x, y); item.getElement().moveBy(newX - oldX, 0);
} }
} }

View File

@@ -252,8 +252,7 @@ export class CodeGenerator {
return opt_thisOnly ? '' : this.blockToCode(block.getChildren(false)[0]); return opt_thisOnly ? '' : this.blockToCode(block.getChildren(false)[0]);
} }
// Look up block generator function in dictionary - but fall back // Look up block generator function in dictionary.
// to looking up on this if not found, for backwards compatibility.
const func = this.forBlock[block.type]; const func = this.forBlock[block.type];
if (typeof func !== 'function') { if (typeof func !== 'function') {
throw Error( throw Error(

View File

@@ -894,7 +894,7 @@ export class Gesture {
'Cannot do a block click because the target block is ' + 'undefined', 'Cannot do a block click because the target block is ' + 'undefined',
); );
} }
if (this.targetBlock.isEnabled()) { if (this.flyout.isBlockCreatable(this.targetBlock)) {
if (!eventUtils.getGroup()) { if (!eventUtils.getGroup()) {
eventUtils.setGroup(true); eventUtils.setGroup(true);
} }

View File

@@ -210,6 +210,9 @@ export class Grid {
* @param rnd A random ID to append to the pattern's ID. * @param rnd A random ID to append to the pattern's ID.
* @param gridOptions The object containing grid configuration. * @param gridOptions The object containing grid configuration.
* @param defs The root SVG element for this workspace's defs. * @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. * @returns The SVG element for the grid pattern.
* @internal * @internal
*/ */
@@ -217,6 +220,7 @@ export class Grid {
rnd: string, rnd: string,
gridOptions: GridOptions, gridOptions: GridOptions,
defs: SVGElement, defs: SVGElement,
injectionDiv?: HTMLElement,
): SVGElement { ): SVGElement {
/* /*
<pattern id="blocklyGridPattern837493" patternUnits="userSpaceOnUse"> <pattern id="blocklyGridPattern837493" patternUnits="userSpaceOnUse">
@@ -247,6 +251,17 @@ export class Grid {
// Edge 16 doesn't handle empty patterns // Edge 16 doesn't handle empty patterns
dom.createSvgElement(Svg.LINE, {}, gridPattern); 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; return gridPattern;
} }
} }

View File

@@ -55,6 +55,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
/** The size of this comment (which is applied to the editable bubble). */ /** The size of this comment (which is applied to the editable bubble). */
private bubbleSize = new Size(DEFAULT_BUBBLE_WIDTH, DEFAULT_BUBBLE_HEIGHT); 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. * The visibility of the bubble for this comment.
* *
@@ -108,7 +111,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}, },
this.svgRoot, this.svgRoot,
); );
dom.addClass(this.svgRoot!, 'blockly-icon-comment'); dom.addClass(this.svgRoot!, 'blocklyCommentIcon');
} }
override dispose() { override dispose() {
@@ -144,7 +147,13 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
} }
override onLocationChange(blockOrigin: Coordinate): void { override onLocationChange(blockOrigin: Coordinate): void {
const oldLocation = this.workspaceLocation;
super.onLocationChange(blockOrigin); 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(); const anchorLocation = this.getAnchorLocation();
this.textInputBubble?.setAnchorLocation(anchorLocation); this.textInputBubble?.setAnchorLocation(anchorLocation);
} }
@@ -184,18 +193,42 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
return this.bubbleSize; 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 * @returns the state of the comment as a JSON serializable value if the
* comment has text. Otherwise returns null. * comment has text. Otherwise returns null.
*/ */
saveState(): CommentState | null { saveState(): CommentState | null {
if (this.text) { if (this.text) {
return { const state: CommentState = {
'text': this.text, 'text': this.text,
'pinned': this.bubbleIsVisible(), 'pinned': this.bubbleIsVisible(),
'height': this.bubbleSize.height, 'height': this.bubbleSize.height,
'width': this.bubbleSize.width, '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; return null;
} }
@@ -209,6 +242,16 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
); );
this.bubbleVisiblity = state['pinned'] ?? false; this.bubbleVisiblity = state['pinned'] ?? false;
this.setBubbleVisible(this.bubbleVisiblity); 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 { override onClick(): void {
@@ -252,6 +295,12 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
} }
} }
onBubbleLocationChange(): void {
if (this.textInputBubble) {
this.bubbleLocation = this.textInputBubble.getRelativeToSurfaceXY();
}
}
bubbleIsVisible(): boolean { bubbleIsVisible(): boolean {
return this.bubbleVisiblity; return this.bubbleVisiblity;
} }
@@ -313,6 +362,14 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
); );
this.textInputBubble.setText(this.getText()); this.textInputBubble.setText(this.getText());
this.textInputBubble.setSize(this.bubbleSize, true); 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. */ /** Hides any open bubbles owned by this comment. */
@@ -355,6 +412,12 @@ export interface CommentState {
/** The width of the comment bubble. */ /** The width of the comment bubble. */
width?: number; 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); registry.register(CommentIcon.TYPE, CommentIcon);

View File

@@ -118,7 +118,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
{'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'}, {'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'},
this.svgRoot, this.svgRoot,
); );
dom.addClass(this.svgRoot!, 'blockly-icon-mutator'); dom.addClass(this.svgRoot!, 'blocklyMutatorIcon');
} }
override dispose(): void { override dispose(): void {

View File

@@ -90,7 +90,7 @@ export class WarningIcon extends Icon implements IHasBubble {
}, },
this.svgRoot, this.svgRoot,
); );
dom.addClass(this.svgRoot!, 'blockly-icon-warning'); dom.addClass(this.svgRoot!, 'blocklyWarningIcon');
} }
override dispose() { override dispose() {

View File

@@ -98,7 +98,7 @@ export function inject(
* @param options Dictionary of options. * @param options Dictionary of options.
* @returns Newly created SVG image. * @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 // Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying
// out content in RTL mode. Therefore Blockly forces the use of LTR, // out content in RTL mode. Therefore Blockly forces the use of LTR,
// then manually positions content in RTL as needed. // then manually positions content in RTL as needed.
@@ -141,7 +141,12 @@ function createDom(container: Element, options: Options): SVGElement {
// https://neil.fraser.name/news/2015/11/01/ // https://neil.fraser.name/news/2015/11/01/
const rnd = String(Math.random()).substring(2); 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; return svg;
} }
@@ -153,7 +158,7 @@ function createDom(container: Element, options: Options): SVGElement {
* @returns Newly created main workspace. * @returns Newly created main workspace.
*/ */
function createMainWorkspace( function createMainWorkspace(
injectionDiv: Element, injectionDiv: HTMLElement,
svg: SVGElement, svg: SVGElement,
options: Options, options: Options,
): WorkspaceSvg { ): WorkspaceSvg {

View File

@@ -20,7 +20,7 @@ import type {Connection} from '../connection.js';
import type {ConnectionType} from '../connection_type.js'; import type {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js'; import type {Field} from '../field.js';
import * as fieldRegistry from '../field_registry.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 {Align} from './align.js';
import {inputTypes} from './input_types.js'; import {inputTypes} from './input_types.js';
@@ -181,15 +181,14 @@ export class Input {
for (let y = 0, field; (field = this.fieldRow[y]); y++) { for (let y = 0, field; (field = this.fieldRow[y]); y++) {
field.setVisible(visible); field.setVisible(visible);
} }
if (this.connection) { if (this.connection && this.connection instanceof RenderedConnection) {
const renderedConnection = this.connection as RenderedConnection;
// Has a connection. // Has a connection.
if (visible) { if (visible) {
renderList = renderedConnection.startTrackingAll(); renderList = this.connection.startTrackingAll();
} else { } else {
renderedConnection.stopTrackingAll(); this.connection.stopTrackingAll();
} }
const child = renderedConnection.targetBlock(); const child = this.connection.targetBlock();
if (child) { if (child) {
child.getSvgRoot().style.display = visible ? 'block' : 'none'; child.getSvgRoot().style.display = visible ? 'block' : 'none';
} }

View File

@@ -1,742 +0,0 @@
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Class that controls updates to connections during drags.
*
* @class
*/
// Former goog.module ID: Blockly.InsertionMarkerManager
import * as blockAnimations from './block_animations.js';
import type {BlockSvg} from './block_svg.js';
import * as common from './common.js';
import {ComponentManager} from './component_manager.js';
import {config} from './config.js';
import * as eventUtils from './events/utils.js';
import type {IDeleteArea} from './interfaces/i_delete_area.js';
import type {IDragTarget} from './interfaces/i_drag_target.js';
import * as renderManagement from './render_management.js';
import {finishQueuedRenders} from './render_management.js';
import type {RenderedConnection} from './rendered_connection.js';
import * as blocks from './serialization/blocks.js';
import type {Coordinate} from './utils/coordinate.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/** Represents a nearby valid connection. */
interface CandidateConnection {
/**
* A nearby valid connection that is compatible with local.
* This is not on any of the blocks that are being dragged.
*/
closest: RenderedConnection;
/**
* A connection on the dragging stack that is compatible with closest. This is
* on the top block that is being dragged or the last block in the dragging
* stack.
*/
local: RenderedConnection;
radius: number;
}
/**
* Class that controls updates to connections during drags. It is primarily
* responsible for finding the closest eligible connection and highlighting or
* unhighlighting it as needed during a drag.
*
* @deprecated v10 - Use an IConnectionPreviewer instead.
*/
export class InsertionMarkerManager {
/**
* The top block in the stack being dragged.
* Does not change during a drag.
*/
private readonly topBlock: BlockSvg;
/**
* The workspace on which these connections are being dragged.
* Does not change during a drag.
*/
private readonly workspace: WorkspaceSvg;
/**
* The last connection on the stack, if it's not the last connection on the
* first block.
* Set in initAvailableConnections, if at all.
*/
private lastOnStack: RenderedConnection | null = null;
/**
* The insertion marker corresponding to the last block in the stack, if
* that's not the same as the first block in the stack.
* Set in initAvailableConnections, if at all
*/
private lastMarker: BlockSvg | null = null;
/**
* The insertion marker that shows up between blocks to show where a block
* would go if dropped immediately.
*/
private firstMarker: BlockSvg;
/**
* Information about the connection that would be made if the dragging block
* were released immediately. Updated on every mouse move.
*/
private activeCandidate: CandidateConnection | null = null;
/**
* Whether the block would be deleted if it were dropped immediately.
* Updated on every mouse move.
*
* @internal
*/
public wouldDeleteBlock = false;
/**
* Connection on the insertion marker block that corresponds to
* the active candidate's local connection on the currently dragged block.
*/
private markerConnection: RenderedConnection | null = null;
/** The block that currently has an input being highlighted, or null. */
private highlightedBlock: BlockSvg | null = null;
/** The block being faded to indicate replacement, or null. */
private fadedBlock: BlockSvg | null = null;
/**
* The connections on the dragging blocks that are available to connect to
* other blocks. This includes all open connections on the top block, as
* well as the last connection on the block stack.
*/
private availableConnections: RenderedConnection[];
/** @param block The top block in the stack being dragged. */
constructor(block: BlockSvg) {
common.setSelected(block);
this.topBlock = block;
this.workspace = block.workspace;
this.firstMarker = this.createMarkerBlock(this.topBlock);
this.availableConnections = this.initAvailableConnections();
if (this.lastOnStack) {
this.lastMarker = this.createMarkerBlock(
this.lastOnStack.getSourceBlock(),
);
}
}
/**
* Sever all links from this object.
*
* @internal
*/
dispose() {
this.availableConnections.length = 0;
this.disposeInsertionMarker(this.firstMarker);
this.disposeInsertionMarker(this.lastMarker);
}
/**
* Update the available connections for the top block. These connections can
* change if a block is unplugged and the stack is healed.
*
* @internal
*/
updateAvailableConnections() {
this.availableConnections = this.initAvailableConnections();
}
/**
* Return whether the block would be connected if dropped immediately, based
* on information from the most recent move event.
*
* @returns True if the block would be connected if dropped immediately.
* @internal
*/
wouldConnectBlock(): boolean {
return !!this.activeCandidate;
}
/**
* Connect to the closest connection and render the results.
* This should be called at the end of a drag.
*
* @internal
*/
applyConnections() {
if (!this.activeCandidate) return;
eventUtils.disable();
this.hidePreview();
eventUtils.enable();
const {local, closest} = this.activeCandidate;
local.connect(closest);
const inferiorConnection = local.isSuperior() ? closest : local;
const rootBlock = this.topBlock.getRootBlock();
finishQueuedRenders().then(() => {
blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock());
// bringToFront is incredibly expensive. Delay until the next frame.
setTimeout(() => {
rootBlock.bringToFront();
}, 0);
});
}
/**
* Update connections based on the most recent move location.
*
* @param dxy Position relative to drag start, in workspace units.
* @param dragTarget The drag target that the block is currently over.
* @internal
*/
update(dxy: Coordinate, dragTarget: IDragTarget | null) {
const newCandidate = this.getCandidate(dxy);
this.wouldDeleteBlock = this.shouldDelete(!!newCandidate, dragTarget);
const shouldUpdate =
this.wouldDeleteBlock || this.shouldUpdatePreviews(newCandidate, dxy);
if (shouldUpdate) {
// Don't fire events for insertion marker creation or movement.
eventUtils.disable();
this.maybeHidePreview(newCandidate);
this.maybeShowPreview(newCandidate);
eventUtils.enable();
}
}
/**
* Create an insertion marker that represents the given block.
*
* @param sourceBlock The block that the insertion marker will represent.
* @returns The insertion marker that represents the given block.
*/
private createMarkerBlock(sourceBlock: BlockSvg): BlockSvg {
eventUtils.disable();
let result: BlockSvg;
try {
const blockJson = blocks.save(sourceBlock, {
addCoordinates: false,
addInputBlocks: false,
addNextBlocks: false,
doFullSerialization: false,
});
if (!blockJson) {
throw new Error(
`Failed to serialize source block. ${sourceBlock.toDevString()}`,
);
}
result = blocks.append(blockJson, this.workspace) as BlockSvg;
// Turn shadow blocks that are created programmatically during
// initalization to insertion markers too.
for (const block of result.getDescendants(false)) {
block.setInsertionMarker(true);
}
result.initSvg();
result.getSvgRoot().setAttribute('visibility', 'hidden');
} finally {
eventUtils.enable();
}
return result;
}
/**
* Populate the list of available connections on this block stack. If the
* stack has more than one block, this function will also update lastOnStack.
*
* @returns A list of available connections.
*/
private initAvailableConnections(): RenderedConnection[] {
const available = this.topBlock.getConnections_(false);
// Also check the last connection on this stack
const lastOnStack = this.topBlock.lastConnectionInStack(true);
if (lastOnStack && lastOnStack !== this.topBlock.nextConnection) {
available.push(lastOnStack);
this.lastOnStack = lastOnStack;
}
return available;
}
/**
* Whether the previews (insertion marker and replacement marker) should be
* updated based on the closest candidate and the current drag distance.
*
* @param newCandidate A new candidate connection that may replace the current
* best candidate.
* @param dxy Position relative to drag start, in workspace units.
* @returns Whether the preview should be updated.
*/
private shouldUpdatePreviews(
newCandidate: CandidateConnection | null,
dxy: Coordinate,
): boolean {
// Only need to update if we were showing a preview before.
if (!newCandidate) return !!this.activeCandidate;
// We weren't showing a preview before, but we should now.
if (!this.activeCandidate) return true;
// We're already showing an insertion marker.
// Decide whether the new connection has higher priority.
const {local: activeLocal, closest: activeClosest} = this.activeCandidate;
if (
activeClosest === newCandidate.closest &&
activeLocal === newCandidate.local
) {
// The connection was the same as the current connection.
return false;
}
const xDiff = activeLocal.x + dxy.x - activeClosest.x;
const yDiff = activeLocal.y + dxy.y - activeClosest.y;
const curDistance = Math.sqrt(xDiff * xDiff + yDiff * yDiff);
// Slightly prefer the existing preview over a new preview.
return (
newCandidate.radius < curDistance - config.currentConnectionPreference
);
}
/**
* Find the nearest valid connection, which may be the same as the current
* closest connection.
*
* @param dxy Position relative to drag start, in workspace units.
* @returns An object containing a local connection, a closest connection, and
* a radius.
*/
private getCandidate(dxy: Coordinate): CandidateConnection | null {
// It's possible that a block has added or removed connections during a
// drag, (e.g. in a drag/move event handler), so let's update the available
// connections. Note that this will be called on every move while dragging,
// so it might cause slowness, especially if the block stack is large. If
// so, maybe it could be made more efficient. Also note that we won't update
// the connections if we've already connected the insertion marker to a
// block.
if (!this.markerConnection || !this.markerConnection.isConnected()) {
this.updateAvailableConnections();
}
let radius = this.getStartRadius();
let candidate = null;
for (let i = 0; i < this.availableConnections.length; i++) {
const myConnection = this.availableConnections[i];
const neighbour = myConnection.closest(radius, dxy);
if (neighbour.connection) {
candidate = {
closest: neighbour.connection,
local: myConnection,
radius: neighbour.radius,
};
radius = neighbour.radius;
}
}
return candidate;
}
/**
* Decide the radius at which to start searching for the closest connection.
*
* @returns The radius at which to start the search for the closest
* connection.
*/
private getStartRadius(): number {
// If there is already a connection highlighted,
// increase the radius we check for making new connections.
// When a connection is highlighted, blocks move around when the
// insertion marker is created, which could cause the connection became out
// of range. By increasing radiusConnection when a connection already
// exists, we never "lose" the connection from the offset.
return this.activeCandidate
? config.connectingSnapRadius
: config.snapRadius;
}
/**
* Whether ending the drag would delete the block.
*
* @param newCandidate Whether there is a candidate connection that the
* block could connect to if the drag ended immediately.
* @param dragTarget The drag target that the block is currently over.
* @returns Whether dropping the block immediately would delete the block.
*/
private shouldDelete(
newCandidate: boolean,
dragTarget: IDragTarget | null,
): boolean {
if (dragTarget) {
const componentManager = this.workspace.getComponentManager();
const isDeleteArea = componentManager.hasCapability(
dragTarget.id,
ComponentManager.Capability.DELETE_AREA,
);
if (isDeleteArea) {
return (dragTarget as IDeleteArea).wouldDelete(this.topBlock);
}
}
return false;
}
/**
* Show an insertion marker or replacement highlighting during a drag, if
* needed.
* At the beginning of this function, this.activeConnection should be null.
*
* @param newCandidate A new candidate connection that may replace the current
* best candidate.
*/
private maybeShowPreview(newCandidate: CandidateConnection | null) {
if (this.wouldDeleteBlock) return; // Nope, don't add a marker.
if (!newCandidate) return; // Nothing to connect to.
const closest = newCandidate.closest;
// Something went wrong and we're trying to connect to an invalid
// connection.
if (
closest === this.activeCandidate?.closest ||
closest.getSourceBlock().isInsertionMarker()
) {
console.log('Trying to connect to an insertion marker');
return;
}
this.activeCandidate = newCandidate;
// Add an insertion marker or replacement marker.
this.showPreview(this.activeCandidate);
}
/**
* A preview should be shown. This function figures out if it should be a
* block highlight or an insertion marker, and shows the appropriate one.
*
* @param activeCandidate The connection that will be made if the drag ends
* immediately.
*/
private showPreview(activeCandidate: CandidateConnection) {
const renderer = this.workspace.getRenderer();
const method = renderer.getConnectionPreviewMethod(
activeCandidate.closest,
activeCandidate.local,
this.topBlock,
);
switch (method) {
case InsertionMarkerManager.PREVIEW_TYPE.INPUT_OUTLINE:
this.showInsertionInputOutline(activeCandidate);
break;
case InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER:
this.showInsertionMarker(activeCandidate);
break;
case InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE:
this.showReplacementFade(activeCandidate);
break;
}
// Optionally highlight the actual connection, as a nod to previous
// behaviour.
if (renderer.shouldHighlightConnection(activeCandidate.closest)) {
activeCandidate.closest.highlight();
}
}
/**
* Hide an insertion marker or replacement highlighting during a drag, if
* needed.
* At the end of this function, this.activeCandidate will be null.
*
* @param newCandidate A new candidate connection that may replace the current
* best candidate.
*/
private maybeHidePreview(newCandidate: CandidateConnection | null) {
// If there's no new preview, remove the old one but don't bother deleting
// it. We might need it later, and this saves disposing of it and recreating
// it.
if (!newCandidate) {
this.hidePreview();
} else {
if (this.activeCandidate) {
const closestChanged =
this.activeCandidate.closest !== newCandidate.closest;
const localChanged = this.activeCandidate.local !== newCandidate.local;
// If there's a new preview and there was a preview before, and either
// connection has changed, remove the old preview.
// Also hide if we had a preview before but now we're going to delete
// instead.
if (closestChanged || localChanged || this.wouldDeleteBlock) {
this.hidePreview();
}
}
}
// Either way, clear out old state.
this.markerConnection = null;
this.activeCandidate = null;
}
/**
* A preview should be hidden. Loop through all possible preview modes
* and hide everything.
*/
private hidePreview() {
const closest = this.activeCandidate?.closest;
if (
closest &&
closest.targetBlock() &&
this.workspace.getRenderer().shouldHighlightConnection(closest)
) {
closest.unhighlight();
}
this.hideReplacementFade();
this.hideInsertionInputOutline();
this.hideInsertionMarker();
}
/**
* Shows an insertion marker connected to the appropriate blocks (based on
* manager state).
*
* @param activeCandidate The connection that will be made if the drag ends
* immediately.
*/
private showInsertionMarker(activeCandidate: CandidateConnection) {
const {local, closest} = activeCandidate;
const isLastInStack = this.lastOnStack && local === this.lastOnStack;
let insertionMarker = isLastInStack ? this.lastMarker : this.firstMarker;
if (!insertionMarker) {
throw new Error(
'Cannot show the insertion marker because there is no insertion ' +
'marker block',
);
}
let imConn;
try {
imConn = insertionMarker.getMatchingConnection(
local.getSourceBlock(),
local,
);
} catch {
// It's possible that the number of connections on the local block has
// changed since the insertion marker was originally created. Let's
// recreate the insertion marker and try again. In theory we could
// probably recreate the marker block (e.g. in getCandidate_), which is
// called more often during the drag, but creating a block that often
// might be too slow, so we only do it if necessary.
if (isLastInStack && this.lastOnStack) {
this.disposeInsertionMarker(this.lastMarker);
this.lastMarker = this.createMarkerBlock(
this.lastOnStack.getSourceBlock(),
);
insertionMarker = this.lastMarker;
} else {
this.disposeInsertionMarker(this.firstMarker);
this.firstMarker = this.createMarkerBlock(this.topBlock);
insertionMarker = this.firstMarker;
}
if (!insertionMarker) {
throw new Error(
'Cannot show the insertion marker because there is no insertion ' +
'marker block',
);
}
imConn = insertionMarker.getMatchingConnection(
local.getSourceBlock(),
local,
);
}
if (!imConn) {
throw new Error(
'Cannot show the insertion marker because there is no ' +
'associated connection',
);
}
if (imConn === this.markerConnection) {
throw new Error(
"Made it to showInsertionMarker_ even though the marker isn't " +
'changing',
);
}
// Render disconnected from everything else so that we have a valid
// connection location.
insertionMarker.queueRender();
renderManagement.triggerQueuedRenders();
// Connect() also renders the insertion marker.
imConn.connect(closest);
const originalOffsetToTarget = {
x: closest.x - imConn.x,
y: closest.y - imConn.y,
};
const originalOffsetInBlock = imConn.getOffsetInBlock().clone();
const imConnConst = imConn;
renderManagement.finishQueuedRenders().then(() => {
// Position so that the existing block doesn't move.
insertionMarker?.positionNearConnection(
imConnConst,
originalOffsetToTarget,
originalOffsetInBlock,
);
insertionMarker?.getSvgRoot().setAttribute('visibility', 'visible');
});
this.markerConnection = imConn;
}
/**
* Disconnects and hides the current insertion marker. Should return the
* blocks to their original state.
*/
private hideInsertionMarker() {
if (!this.markerConnection) return;
const markerConn = this.markerConnection;
const imBlock = markerConn.getSourceBlock();
const markerPrev = imBlock.previousConnection;
const markerOutput = imBlock.outputConnection;
if (!markerPrev?.targetConnection && !markerOutput?.targetConnection) {
// If we are the top block, unplugging doesn't do anything.
// The marker connection may not have a target block if we are hiding
// as part of applying connections.
markerConn.targetBlock()?.unplug(false);
} else {
imBlock.unplug(true);
}
if (markerConn.targetConnection) {
throw Error(
'markerConnection still connected at the end of ' +
'disconnectInsertionMarker',
);
}
this.markerConnection = null;
const svg = imBlock.getSvgRoot();
if (svg) {
svg.setAttribute('visibility', 'hidden');
}
}
/**
* Shows an outline around the input the closest connection belongs to.
*
* @param activeCandidate The connection that will be made if the drag ends
* immediately.
*/
private showInsertionInputOutline(activeCandidate: CandidateConnection) {
const closest = activeCandidate.closest;
this.highlightedBlock = closest.getSourceBlock();
this.highlightedBlock.highlightShapeForInput(closest, true);
}
/** Hides any visible input outlines. */
private hideInsertionInputOutline() {
if (!this.highlightedBlock) return;
if (!this.activeCandidate) {
throw new Error(
'Cannot hide the insertion marker outline because ' +
'there is no active candidate',
);
}
this.highlightedBlock.highlightShapeForInput(
this.activeCandidate.closest,
false,
);
this.highlightedBlock = null;
}
/**
* Shows a replacement fade affect on the closest connection's target block
* (the block that is currently connected to it).
*
* @param activeCandidate The connection that will be made if the drag ends
* immediately.
*/
private showReplacementFade(activeCandidate: CandidateConnection) {
this.fadedBlock = activeCandidate.closest.targetBlock();
if (!this.fadedBlock) {
throw new Error(
'Cannot show the replacement fade because the ' +
'closest connection does not have a target block',
);
}
this.fadedBlock.fadeForReplacement(true);
}
/**
* Hides/Removes any visible fade affects.
*/
private hideReplacementFade() {
if (!this.fadedBlock) return;
this.fadedBlock.fadeForReplacement(false);
this.fadedBlock = null;
}
/**
* Get a list of the insertion markers that currently exist. Drags have 0, 1,
* or 2 insertion markers.
*
* @returns A possibly empty list of insertion marker blocks.
* @internal
*/
getInsertionMarkers(): BlockSvg[] {
const result = [];
if (this.firstMarker) {
result.push(this.firstMarker);
}
if (this.lastMarker) {
result.push(this.lastMarker);
}
return result;
}
/**
* Safely disposes of an insertion marker.
*/
private disposeInsertionMarker(marker: BlockSvg | null) {
if (marker) {
eventUtils.disable();
try {
marker.dispose();
} finally {
eventUtils.enable();
}
}
}
}
export namespace InsertionMarkerManager {
/**
* An enum describing different kinds of previews the InsertionMarkerManager
* could display.
*/
export enum PREVIEW_TYPE {
INSERTION_MARKER = 0,
INPUT_OUTLINE = 1,
REPLACEMENT_FADE = 2,
}
}
export type PreviewType = InsertionMarkerManager.PREVIEW_TYPE;
export const PreviewType = InsertionMarkerManager.PREVIEW_TYPE;

View File

@@ -6,6 +6,7 @@
import {CommentState} from '../icons/comment_icon.js'; import {CommentState} from '../icons/comment_icon.js';
import {IconType} from '../icons/icon_types.js'; import {IconType} from '../icons/icon_types.js';
import {Coordinate} from '../utils/coordinate.js';
import {Size} from '../utils/size.js'; import {Size} from '../utils/size.js';
import {IHasBubble, hasBubble} from './i_has_bubble.js'; import {IHasBubble, hasBubble} from './i_has_bubble.js';
import {IIcon, isIcon} from './i_icon.js'; import {IIcon, isIcon} from './i_icon.js';
@@ -20,6 +21,10 @@ export interface ICommentIcon extends IIcon, IHasBubble, ISerializable {
getBubbleSize(): Size; getBubbleSize(): Size;
setBubbleLocation(location: Coordinate): void;
getBubbleLocation(): Coordinate | undefined;
saveState(): CommentState; saveState(): CommentState;
loadState(state: CommentState): void; loadState(state: CommentState): void;
@@ -35,6 +40,8 @@ export function isCommentIcon(obj: object): obj is ICommentIcon {
(obj as any)['getText'] !== undefined && (obj as any)['getText'] !== undefined &&
(obj as any)['setBubbleSize'] !== undefined && (obj as any)['setBubbleSize'] !== undefined &&
(obj as any)['getBubbleSize'] !== undefined && (obj as any)['getBubbleSize'] !== undefined &&
(obj as any)['setBubbleLocation'] !== undefined &&
(obj as any)['getBubbleLocation'] !== undefined &&
obj.getType() === IconType.COMMENT obj.getType() === IconType.COMMENT
); );
} }

View File

@@ -7,7 +7,7 @@
// Former goog.module ID: Blockly.IFlyout // Former goog.module ID: Blockly.IFlyout
import type {BlockSvg} from '../block_svg.js'; 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 {Coordinate} from '../utils/coordinate.js';
import type {Svg} from '../utils/svg.js'; import type {Svg} from '../utils/svg.js';
import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {FlyoutDefinition} from '../utils/toolbox.js';

View File

@@ -0,0 +1,51 @@
import type {FlyoutItem} from '../flyout_item.js';
import type {IFlyout} from './i_flyout.js';
export interface IFlyoutInflater {
/**
* Loads the object represented by the given state onto the workspace.
*
* Note that this method's interface is identical to that in ISerializer, to
* allow for code reuse.
*
* @param state A JSON representation of an element to inflate on the flyout.
* @param flyout The flyout on whose workspace the inflated element
* should be created. If the inflated element is an `IRenderedElement` it
* itself or the inflater should append it to the workspace; the flyout
* will not do so itself. The flyout is responsible for positioning the
* element, however.
* @returns The newly inflated flyout element.
*/
load(state: object, flyout: IFlyout): FlyoutItem;
/**
* Returns the amount of spacing that should follow the element corresponding
* to the given JSON representation.
*
* @param state A JSON representation of the element preceding the gap.
* @param defaultGap The default gap for elements in this flyout.
* @returns The gap that should follow the given element.
*/
gapForItem(state: object, defaultGap: number): number;
/**
* Disposes of the given element.
*
* If the element in question resides on the flyout workspace, it should remove
* itself. Implementers are not otherwise required to fully dispose of the
* element; it may be e.g. cached for performance purposes.
*
* @param element The flyout element to dispose of.
*/
disposeItem(item: FlyoutItem): void;
/**
* Returns the type of items that this inflater is responsible for inflating.
* This should be the same as the name under which this inflater registers
* itself, as well as the value returned by `getType()` on the `FlyoutItem`
* objects returned by `load()`.
*
* @returns The type of items this inflater creates.
*/
getType(): string;
}

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableTree} from './i_focusable_tree.js';
/** Represents anything that can have input focus. */
export interface IFocusableNode {
/**
* Returns the DOM element that can be explicitly requested to receive focus.
*
* IMPORTANT: Please note that this element is expected to have a visual
* presence on the page as it will both be explicitly focused and have its
* style changed depending on its current focus state (i.e. blurred, actively
* focused, and passively focused). The element will have one of two styles
* attached (where no style indicates blurred/not focused):
* - blocklyActiveFocus
* - blocklyPassiveFocus
*
* The returned element must also have a valid ID specified, and unique to the
* element relative to its nearest IFocusableTree parent.
*
* It's expected the return element will not change for the lifetime of the
* 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.
*/
getFocusableTree(): IFocusableTree;
}

View File

@@ -0,0 +1,53 @@
/**
* @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.
*/
export interface IFocusableTree {
/**
* Returns the current node with focus in this tree, or null if none (or if
* the root has focus).
*
* Note that this will never return a node from a nested sub-tree as that tree
* should specifically be called in order to retrieve its focused node.
*/
getFocusedNode(): IFocusableNode | null;
/**
* 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 corresponding to the select element, or null if
* the element does not have such a node.
*
* The provided element must have a non-null ID that conforms to the contract
* mentioned in IFocusableNode.
*/
findFocusableNodeFor(
element: HTMLElement | SVGElement,
): IFocusableNode | null;
}

View File

@@ -63,7 +63,7 @@ export interface IMetricsManager {
* Gets the width, height and position of the toolbox on the workspace in * 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 * 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 * 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. * @returns The object with the width, height and position of the toolbox.
*/ */

View File

@@ -4,18 +4,15 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
/** @internal */
export interface IRenderedElement { export interface IRenderedElement {
/** /**
* @returns The root SVG element of htis rendered element. * @returns The root SVG element of this rendered element.
*/ */
getSvgRoot(): SVGElement; getSvgRoot(): SVGElement;
} }
/** /**
* @returns True if the given object is an IRenderedElement. * @returns True if the given object is an IRenderedElement.
*
* @internal
*/ */
export function isRenderedElement(obj: any): obj is IRenderedElement { export function isRenderedElement(obj: any): obj is IRenderedElement {
return obj['getSvgRoot'] !== undefined; return obj['getSvgRoot'] !== undefined;

View File

@@ -94,7 +94,7 @@ export interface IToolbox extends IRegistrable {
setVisible(isVisible: boolean): void; 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. * @param position The position of the item to select.
*/ */
@@ -107,6 +107,14 @@ export interface IToolbox extends IRegistrable {
*/ */
getSelectedItem(): IToolboxItem | null; 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. */ /** Disposes of this toolbox. */
dispose(): void; dispose(): void;
} }

View File

@@ -4,13 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type {VariableModel} from '../variable_model.js';
import {IParameterModel} from './i_parameter_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. */ /** Interface for a parameter model that holds a variable model. */
export interface IVariableBackedParameterModel extends IParameterModel { export interface IVariableBackedParameterModel extends IParameterModel {
/** Returns the variable model held by this type. */ /** Returns the variable model held by this type. */
getVariableModel(): VariableModel; getVariableModel(): IVariableModel<IVariableState>;
} }
/** /**

View File

@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IVariableModel, IVariableState} from './i_variable_model.js';
/**
* Variable maps are container objects responsible for storing and managing the
* set of variables referenced on a workspace.
*
* Any of these methods may define invariants about which names and types are
* legal, and throw if they are not met.
*/
export interface IVariableMap<T extends IVariableModel<IVariableState>> {
/* Returns the variable corresponding to the given ID, or null if none. */
getVariableById(id: string): T | null;
/**
* Returns the variable with the given name, or null if not found. If `type`
* is provided, the variable's type must also match, or null should be
* returned.
*/
getVariable(name: string, type?: string): T | null;
/* Returns a list of all variables managed by this variable map. */
getAllVariables(): T[];
/**
* Returns a list of all of the variables of the given type managed by this
* variable map.
*/
getVariablesOfType(type: string): T[];
/**
* Returns a list of the set of types of the variables managed by this
* variable map.
*/
getTypes(): string[];
/**
* Creates a new variable with the given name. If ID is not specified, the
* variable map should create one. Returns the new variable.
*/
createVariable(name: string, id?: string, type?: string | null): T;
/* Adds a variable to this variable map. */
addVariable(variable: T): void;
/**
* Changes the name of the given variable to the name provided and returns the
* renamed variable.
*/
renameVariable(variable: T, newName: string): T;
/* Changes the type of the given variable and returns it. */
changeVariableType(variable: T, newType: string): T;
/* Deletes the given variable. */
deleteVariable(variable: T): void;
/* Removes all variables from this variable map. */
clear(): void;
}

View File

@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {Workspace} from '../workspace.js';
/* Representation of a variable. */
export interface IVariableModel<T extends IVariableState> {
/* Returns the unique ID of this variable. */
getId(): string;
/* Returns the user-visible name of this variable. */
getName(): string;
/**
* Returns the type of the variable like 'int' or 'string'. Does not need to be
* unique. This will default to '' which is a specific type.
*/
getType(): string;
/* Sets the user-visible name of this variable. */
setName(name: string): this;
/* Sets the type of this variable. */
setType(type: string): this;
getWorkspace(): Workspace;
/* Serializes this variable */
save(): T;
}
export interface IVariableModelStatic<T extends IVariableState> {
new (
workspace: Workspace,
name: string,
type?: string,
id?: string,
): IVariableModel<T>;
/**
* Creates a new IVariableModel corresponding to the given state on the
* specified workspace. This method must be static in your implementation.
*/
load(state: T, workspace: Workspace): IVariableModel<T>;
}
/**
* Represents the state of a given variable.
*/
export interface IVariableState {
name: string;
id: string;
type?: string;
}

View File

@@ -13,11 +13,12 @@
// Former goog.module ID: Blockly.ASTNode // Former goog.module ID: Blockly.ASTNode
import {Block} from '../block.js'; import {Block} from '../block.js';
import {BlockSvg} from '../block_svg.js';
import type {Connection} from '../connection.js'; import type {Connection} from '../connection.js';
import {ConnectionType} from '../connection_type.js'; import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js'; import type {Field} from '../field.js';
import {FlyoutItem} from '../flyout_base.js';
import {FlyoutButton} from '../flyout_button.js'; import {FlyoutButton} from '../flyout_button.js';
import type {FlyoutItem} from '../flyout_item.js';
import type {Input} from '../inputs/input.js'; import type {Input} from '../inputs/input.js';
import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js'; import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js';
import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js'; import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js';
@@ -347,10 +348,11 @@ export class ASTNode {
); );
if (!nextItem) return null; if (!nextItem) return null;
if (nextItem.type === 'button' && nextItem.button) { const element = nextItem.getElement();
return ASTNode.createButtonNode(nextItem.button); if (element instanceof FlyoutButton) {
} else if (nextItem.type === 'block' && nextItem.block) { return ASTNode.createButtonNode(element);
return ASTNode.createStackNode(nextItem.block); } else if (element instanceof BlockSvg) {
return ASTNode.createStackNode(element);
} }
return null; return null;
@@ -370,12 +372,15 @@ export class ASTNode {
forward: boolean, forward: boolean,
): FlyoutItem | null { ): FlyoutItem | null {
const currentIndex = flyoutContents.findIndex((item: FlyoutItem) => { const currentIndex = flyoutContents.findIndex((item: FlyoutItem) => {
if (currentLocation instanceof Block && item.block === currentLocation) { if (
currentLocation instanceof BlockSvg &&
item.getElement() === currentLocation
) {
return true; return true;
} }
if ( if (
currentLocation instanceof FlyoutButton && currentLocation instanceof FlyoutButton &&
item.button === currentLocation item.getElement() === currentLocation
) { ) {
return true; return true;
} }
@@ -384,7 +389,17 @@ export class ASTNode {
if (currentIndex < 0) return null; if (currentIndex < 0) return null;
const resultIndex = forward ? currentIndex + 1 : currentIndex - 1; let resultIndex = forward ? currentIndex + 1 : currentIndex - 1;
// The flyout may contain non-focusable elements like spacers or custom
// items. If the next/previous element is one of those, keep looking until a
// focusable element is encountered.
while (
resultIndex >= 0 &&
resultIndex < flyoutContents.length &&
!flyoutContents[resultIndex].isFocusable()
) {
resultIndex += forward ? 1 : -1;
}
if (resultIndex === -1 || resultIndex === flyoutContents.length) { if (resultIndex === -1 || resultIndex === flyoutContents.length) {
return null; return null;
} }

View File

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

View File

@@ -12,10 +12,10 @@
// Former goog.module ID: Blockly.Menu // Former goog.module ID: Blockly.Menu
import * as browserEvents from './browser_events.js'; import * as browserEvents from './browser_events.js';
import type {MenuItem} from './menuitem.js'; import type {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import type {Size} from './utils/size.js'; import type {Size} from './utils/size.js';
import * as style from './utils/style.js'; import * as style from './utils/style.js';
@@ -24,11 +24,9 @@ import * as style from './utils/style.js';
*/ */
export class Menu { export class Menu {
/** /**
* Array of menu items. * Array of menu items and separators.
* (Nulls are never in the array, but typing the array as nullable prevents
* the compiler from objecting to .indexOf(null))
*/ */
private readonly menuItems: MenuItem[] = []; private readonly menuItems: Array<MenuItem | MenuSeparator> = [];
/** /**
* Coordinates of the pointerdown event that caused this menu to open. Used to * Coordinates of the pointerdown event that caused this menu to open. Used to
@@ -70,10 +68,10 @@ export class Menu {
/** /**
* Add a new menu item to the bottom of this menu. * Add a new menu item to the bottom of this menu.
* *
* @param menuItem Menu item to append. * @param menuItem Menu item or separator to append.
* @internal * @internal
*/ */
addChild(menuItem: MenuItem) { addChild(menuItem: MenuItem | MenuSeparator) {
this.menuItems.push(menuItem); this.menuItems.push(menuItem);
} }
@@ -83,10 +81,10 @@ export class Menu {
* @param container Element upon which to append this menu. * @param container Element upon which to append this menu.
* @returns The menu's root DOM element. * @returns The menu's root DOM element.
*/ */
render(container: Element): HTMLDivElement { render(container: Element): HTMLDivElement {
const element = document.createElement('div'); const element = document.createElement('div');
// goog-menu is deprecated, use blocklyMenu. May 2020. element.className = 'blocklyMenu';
element.className = 'blocklyMenu goog-menu blocklyNonSelectable';
element.tabIndex = 0; element.tabIndex = 0;
if (this.roleName) { if (this.roleName) {
aria.setRole(element, this.roleName); aria.setRole(element, this.roleName);
@@ -157,7 +155,6 @@ export class Menu {
const el = this.getElement(); const el = this.getElement();
if (el) { if (el) {
el.focus({preventScroll: true}); el.focus({preventScroll: true});
dom.addClass(el, 'blocklyFocused');
} }
} }
@@ -166,7 +163,6 @@ export class Menu {
const el = this.getElement(); const el = this.getElement();
if (el) { if (el) {
el.blur(); el.blur();
dom.removeClass(el, 'blocklyFocused');
} }
} }
@@ -230,7 +226,8 @@ export class Menu {
while (currentElement && currentElement !== menuElem) { while (currentElement && currentElement !== menuElem) {
if (currentElement.classList.contains('blocklyMenuItem')) { if (currentElement.classList.contains('blocklyMenuItem')) {
// Having found a menu item's div, locate that menu item in this menu. // Having found a menu item's div, locate that menu item in this menu.
for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) { const items = this.getMenuItems();
for (let i = 0, menuItem; (menuItem = items[i]); i++) {
if (menuItem.getElement() === currentElement) { if (menuItem.getElement() === currentElement) {
return menuItem; return menuItem;
} }
@@ -252,11 +249,9 @@ export class Menu {
setHighlighted(item: MenuItem | null) { setHighlighted(item: MenuItem | null) {
const currentHighlighted = this.highlightedItem; const currentHighlighted = this.highlightedItem;
if (currentHighlighted) { if (currentHighlighted) {
currentHighlighted.setHighlighted(false);
this.highlightedItem = null; this.highlightedItem = null;
} }
if (item) { if (item) {
item.setHighlighted(true);
this.highlightedItem = item; this.highlightedItem = item;
// Bring the highlighted item into view. This has no effect if the menu is // Bring the highlighted item into view. This has no effect if the menu is
// not scrollable. // not scrollable.
@@ -316,7 +311,8 @@ export class Menu {
private highlightHelper(startIndex: number, delta: number) { private highlightHelper(startIndex: number, delta: number) {
let index = startIndex + delta; let index = startIndex + delta;
let menuItem; let menuItem;
while ((menuItem = this.menuItems[index])) { const items = this.getMenuItems();
while ((menuItem = items[index])) {
if (menuItem.isEnabled()) { if (menuItem.isEnabled()) {
this.setHighlighted(menuItem); this.setHighlighted(menuItem);
break; break;
@@ -408,9 +404,7 @@ export class Menu {
// Keyboard events. // Keyboard events.
/** /**
* Attempts to handle a keyboard event, if the menu item is enabled, by * Attempts to handle a keyboard event.
* calling
* {@link Menu#handleKeyEventInternal_}.
* *
* @param e Key event to handle. * @param e Key event to handle.
*/ */
@@ -479,4 +473,13 @@ export class Menu {
menuSize.height = menuDom.scrollHeight; menuSize.height = menuDom.scrollHeight;
return menuSize; return menuSize;
} }
/**
* Returns the action menu items (omitting separators) in this menu.
*
* @returns The MenuItem objects displayed in this menu.
*/
private getMenuItems(): MenuItem[] {
return this.menuItems.filter((item) => item instanceof MenuItem);
}
} }

38
core/menu_separator.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as aria from './utils/aria.js';
/**
* Representation of a section separator in a menu.
*/
export class MenuSeparator {
/**
* DOM element representing this separator in a menu.
*/
private element: HTMLHRElement | null = null;
/**
* Creates the DOM representation of this separator.
*
* @returns An <hr> element.
*/
createDom(): HTMLHRElement {
this.element = document.createElement('hr');
this.element.className = 'blocklyMenuSeparator';
aria.setRole(this.element, aria.Role.SEPARATOR);
return this.element;
}
/**
* Disposes of this separator.
*/
dispose() {
this.element?.remove();
this.element = null;
}
}

View File

@@ -12,7 +12,6 @@
// Former goog.module ID: Blockly.MenuItem // Former goog.module ID: Blockly.MenuItem
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js'; import * as idGenerator from './utils/idgenerator.js';
/** /**
@@ -64,22 +63,18 @@ export class MenuItem {
this.element = element; this.element = element;
// Set class and style // Set class and style
// goog-menuitem* is deprecated, use blocklyMenuItem*. May 2020.
element.className = element.className =
'blocklyMenuItem goog-menuitem ' + 'blocklyMenuItem ' +
(this.enabled ? '' : 'blocklyMenuItemDisabled goog-menuitem-disabled ') + (this.enabled ? '' : 'blocklyMenuItemDisabled ') +
(this.checked ? 'blocklyMenuItemSelected goog-option-selected ' : '') + (this.checked ? 'blocklyMenuItemSelected ' : '') +
(this.highlight (this.rightToLeft ? 'blocklyMenuItemRtl ' : '');
? 'blocklyMenuItemHighlight goog-menuitem-highlight '
: '') +
(this.rightToLeft ? 'blocklyMenuItemRtl goog-menuitem-rtl ' : '');
const content = document.createElement('div'); const content = document.createElement('div');
content.className = 'blocklyMenuItemContent goog-menuitem-content'; content.className = 'blocklyMenuItemContent';
// Add a checkbox for checkable menu items. // Add a checkbox for checkable menu items.
if (this.checkable) { if (this.checkable) {
const checkbox = document.createElement('div'); const checkbox = document.createElement('div');
checkbox.className = 'blocklyMenuItemCheckbox goog-menuitem-checkbox'; checkbox.className = 'blocklyMenuItemCheckbox ';
content.appendChild(checkbox); content.appendChild(checkbox);
} }
@@ -180,31 +175,6 @@ export class MenuItem {
this.checked = checked; this.checked = checked;
} }
/**
* Highlights or unhighlights the component.
*
* @param highlight Whether to highlight or unhighlight the component.
* @internal
*/
setHighlighted(highlight: boolean) {
this.highlight = highlight;
const el = this.getElement();
if (el && this.isEnabled()) {
// goog-menuitem-highlight is deprecated, use blocklyMenuItemHighlight.
// May 2020.
const name = 'blocklyMenuItemHighlight';
const nameDep = 'goog-menuitem-highlight';
if (highlight) {
dom.addClass(el, name);
dom.addClass(el, nameDep);
} else {
dom.removeClass(el, name);
dom.removeClass(el, nameDep);
}
}
}
/** /**
* Returns true if the menu item is enabled, false otherwise. * Returns true if the menu item is enabled, false otherwise.
* *

View File

@@ -76,7 +76,7 @@ export class MetricsManager implements IMetricsManager {
* Gets the width, height and position of the toolbox on the workspace in * 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 * 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 * a simple toolbox instead of a category toolbox. To get the width and height
* of a simple toolbox, see {@link MetricsManager#getFlyoutMetrics}. * of a simple toolbox, see {@link (MetricsManager:class).getFlyoutMetrics}.
* *
* @returns The object with the width, height and position of the toolbox. * @returns The object with the width, height and position of the toolbox.
*/ */

View File

@@ -11,9 +11,12 @@
*/ */
// Former goog.module ID: Blockly.Names // Former goog.module ID: Blockly.Names
import type {IVariableMap} from './interfaces/i_variable_map.js';
import type {
IVariableModel,
IVariableState,
} from './interfaces/i_variable_model.js';
import {Msg} from './msg.js'; import {Msg} from './msg.js';
// import * as Procedures from './procedures.js';
import type {VariableMap} from './variable_map.js';
import * as Variables from './variables.js'; import * as Variables from './variables.js';
import type {Workspace} from './workspace.js'; import type {Workspace} from './workspace.js';
@@ -39,7 +42,8 @@ export class Names {
/** /**
* The variable map from the workspace, containing Blockly variable models. * The variable map from the workspace, containing Blockly variable models.
*/ */
private variableMap: VariableMap | null = null; private variableMap: IVariableMap<IVariableModel<IVariableState>> | null =
null;
/** /**
* @param reservedWordsList A comma-separated string of words that are illegal * @param reservedWordsList A comma-separated string of words that are illegal
@@ -70,7 +74,7 @@ export class Names {
* *
* @param map The map to track. * @param map The map to track.
*/ */
setVariableMap(map: VariableMap) { setVariableMap(map: IVariableMap<IVariableModel<IVariableState>>) {
this.variableMap = map; this.variableMap = map;
} }
@@ -95,7 +99,7 @@ export class Names {
} }
const variable = this.variableMap.getVariableById(id); const variable = this.variableMap.getVariableById(id);
if (variable) { if (variable) {
return variable.name; return variable.getName();
} }
return null; return null;
} }

View File

@@ -42,6 +42,8 @@ import {IProcedureModel} from './interfaces/i_procedure_model.js';
import {Msg} from './msg.js'; import {Msg} from './msg.js';
import {Names} from './names.js'; import {Names} from './names.js';
import {ObservableProcedureMap} from './observable_procedure_map.js'; import {ObservableProcedureMap} from './observable_procedure_map.js';
import * as deprecation from './utils/deprecation.js';
import type {FlyoutItemInfo} from './utils/toolbox.js';
import * as utilsXml from './utils/xml.js'; import * as utilsXml from './utils/xml.js';
import * as Variables from './variables.js'; import * as Variables from './variables.js';
import type {Workspace} from './workspace.js'; import type {Workspace} from './workspace.js';
@@ -238,7 +240,7 @@ export function rename(this: Field, name: string): string {
* @param workspace The workspace containing procedures. * @param workspace The workspace containing procedures.
* @returns Array of XML block elements. * @returns Array of XML block elements.
*/ */
export function flyoutCategory(workspace: WorkspaceSvg): Element[] { function xmlFlyoutCategory(workspace: WorkspaceSvg): Element[] {
const xmlList = []; const xmlList = [];
if (Blocks['procedures_defnoreturn']) { if (Blocks['procedures_defnoreturn']) {
// <block type="procedures_defnoreturn" gap="16"> // <block type="procedures_defnoreturn" gap="16">
@@ -322,6 +324,109 @@ export function flyoutCategory(workspace: WorkspaceSvg): Element[] {
return xmlList; return xmlList;
} }
/**
* Internal wrapper that returns the contents of the procedure category.
*
* @internal
* @param workspace The workspace to populate procedure blocks for.
*/
export function internalFlyoutCategory(
workspace: WorkspaceSvg,
): FlyoutItemInfo[] {
return flyoutCategory(workspace, false);
}
export function flyoutCategory(
workspace: WorkspaceSvg,
useXml: true,
): Element[];
export function flyoutCategory(
workspace: WorkspaceSvg,
useXml: false,
): FlyoutItemInfo[];
/**
* Construct the blocks required by the flyout for the procedure category.
*
* @param workspace The workspace containing procedures.
* @param useXml True to return the contents as XML, false to use JSON.
* @returns List of flyout contents as either XML or JSON.
*/
export function flyoutCategory(
workspace: WorkspaceSvg,
useXml = true,
): Element[] | FlyoutItemInfo[] {
if (useXml) {
deprecation.warn(
'The XML return value of Blockly.Procedures.flyoutCategory()',
'v12',
'v13',
'the same method, but handle a return type of FlyoutItemInfo[] (JSON) instead.',
);
return xmlFlyoutCategory(workspace);
}
const blocks = [];
if (Blocks['procedures_defnoreturn']) {
blocks.push({
'kind': 'block',
'type': 'procedures_defnoreturn',
'gap': 16,
'fields': {
'NAME': Msg['PROCEDURES_DEFNORETURN_PROCEDURE'],
},
});
}
if (Blocks['procedures_defreturn']) {
blocks.push({
'kind': 'block',
'type': 'procedures_defreturn',
'gap': 16,
'fields': {
'NAME': Msg['PROCEDURES_DEFRETURN_PROCEDURE'],
},
});
}
if (Blocks['procedures_ifreturn']) {
blocks.push({
'kind': 'block',
'type': 'procedures_ifreturn',
'gap': 16,
});
}
if (blocks.length) {
// Add slightly larger gap between system blocks and user calls.
blocks[blocks.length - 1]['gap'] = 24;
}
/**
* Creates JSON block definitions for each of the given procedures.
*
* @param procedureList A list of procedures, each of which is defined by a
* three-element list of name, parameter list, and return value boolean.
* @param templateName The type of the block to generate.
*/
function populateProcedures(
procedureList: ProcedureTuple[],
templateName: string,
) {
for (const [name, args] of procedureList) {
blocks.push({
'kind': 'block',
'type': templateName,
'gap': 16,
'extraState': {
'name': name,
'params': args,
},
});
}
}
const tuple = allProcedures(workspace);
populateProcedures(tuple[0], 'procedures_callnoreturn');
populateProcedures(tuple[1], 'procedures_callreturn');
return blocks;
}
/** /**
* Updates the procedure mutator's flyout so that the arg block is not a * Updates the procedure mutator's flyout so that the arg block is not a
* duplicate of another arg. * duplicate of another arg.

View File

@@ -14,11 +14,18 @@ import type {IConnectionPreviewer} from './interfaces/i_connection_previewer.js'
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
import type {IDragger} from './interfaces/i_dragger.js'; import type {IDragger} from './interfaces/i_dragger.js';
import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import type {IIcon} from './interfaces/i_icon.js'; import type {IIcon} from './interfaces/i_icon.js';
import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
import type {IPaster} from './interfaces/i_paster.js'; import type {IPaster} from './interfaces/i_paster.js';
import type {ISerializer} from './interfaces/i_serializer.js'; import type {ISerializer} from './interfaces/i_serializer.js';
import type {IToolbox} from './interfaces/i_toolbox.js'; import type {IToolbox} from './interfaces/i_toolbox.js';
import type {IVariableMap} from './interfaces/i_variable_map.js';
import type {
IVariableModel,
IVariableModelStatic,
IVariableState,
} from './interfaces/i_variable_model.js';
import type {Cursor} from './keyboard_nav/cursor.js'; import type {Cursor} from './keyboard_nav/cursor.js';
import type {Options} from './options.js'; import type {Options} from './options.js';
import type {Renderer} from './renderers/common/renderer.js'; import type {Renderer} from './renderers/common/renderer.js';
@@ -93,6 +100,8 @@ export class Type<_T> {
'flyoutsHorizontalToolbox', 'flyoutsHorizontalToolbox',
); );
static FLYOUT_INFLATER = new Type<IFlyoutInflater>('flyoutInflater');
static METRICS_MANAGER = new Type<IMetricsManager>('metricsManager'); static METRICS_MANAGER = new Type<IMetricsManager>('metricsManager');
/** /**
@@ -109,6 +118,14 @@ export class Type<_T> {
/** @internal */ /** @internal */
static PASTER = new Type<IPaster<ICopyData, ICopyable<ICopyData>>>('paster'); static PASTER = new Type<IPaster<ICopyData, ICopyable<ICopyData>>>('paster');
static VARIABLE_MODEL = new Type<IVariableModelStatic<IVariableState>>(
'variableModel',
);
static VARIABLE_MAP = new Type<IVariableMap<IVariableModel<IVariableState>>>(
'variableMap',
);
} }
/** /**

View File

@@ -727,7 +727,10 @@ export class ConstantProvider {
svgPaths.point(70, -height), svgPaths.point(70, -height),
svgPaths.point(width, 0), svgPaths.point(width, 0),
]); ]);
return {height, width, path: mainPath}; // Height is actually the Y position of the control points defining the
// curve of the hat; the hat's actual rendered height is 3/4 of the control
// points' Y position, per https://stackoverflow.com/a/5327329
return {height: height * 0.75, width, path: mainPath};
} }
/** /**
@@ -923,8 +926,18 @@ export class ConstantProvider {
* @param svg The root of the workspace's SVG. * @param svg The root of the workspace's SVG.
* @param tagName The name to use for the CSS style tag. * @param tagName The name to use for the CSS style tag.
* @param selector The CSS selector to use. * @param selector The CSS selector to use.
* @param injectionDivIfIsParent The div containing the parent workspace and
* all related workspaces and block containers, if this renderer is for the
* parent workspace. CSS variables representing SVG patterns will be scoped
* to this container. Child workspaces should not override the CSS variables
* created by the parent and thus do not need access to the injection div.
*/ */
createDom(svg: SVGElement, tagName: string, selector: string) { createDom(
svg: SVGElement,
tagName: string,
selector: string,
injectionDivIfIsParent?: HTMLElement,
) {
this.injectCSS_(tagName, selector); this.injectCSS_(tagName, selector);
/* /*
@@ -1031,6 +1044,24 @@ export class ConstantProvider {
this.disabledPattern = disabledPattern; this.disabledPattern = disabledPattern;
this.createDebugFilter(); this.createDebugFilter();
if (injectionDivIfIsParent) {
// If this renderer is for the parent workspace, add CSS variables scoped
// to the injection div referencing the created patterns so that CSS can
// apply the patterns to any element in the injection div.
injectionDivIfIsParent.style.setProperty(
'--blocklyEmbossFilter',
`url(#${this.embossFilterId})`,
);
injectionDivIfIsParent.style.setProperty(
'--blocklyDisabledPattern',
`url(#${this.disabledPatternId})`,
);
injectionDivIfIsParent.style.setProperty(
'--blocklyDebugFilter',
`url(#${this.debugFilterId})`,
);
}
} }
/** /**
@@ -1132,14 +1163,14 @@ export class ConstantProvider {
`${selector} .blocklyText {`, `${selector} .blocklyText {`,
`fill: #fff;`, `fill: #fff;`,
`}`, `}`,
`${selector} .blocklyNonEditableText>rect,`, `${selector} .blocklyNonEditableField>rect,`,
`${selector} .blocklyEditableText>rect {`, `${selector} .blocklyEditableField>rect {`,
`fill: ${this.FIELD_BORDER_RECT_COLOUR};`, `fill: ${this.FIELD_BORDER_RECT_COLOUR};`,
`fill-opacity: .6;`, `fill-opacity: .6;`,
`stroke: none;`, `stroke: none;`,
`}`, `}`,
`${selector} .blocklyNonEditableText>text,`, `${selector} .blocklyNonEditableField>text,`,
`${selector} .blocklyEditableText>text {`, `${selector} .blocklyEditableField>text {`,
`fill: #000;`, `fill: #000;`,
`}`, `}`,
@@ -1154,7 +1185,7 @@ export class ConstantProvider {
`}`, `}`,
// Editable field hover. // Editable field hover.
`${selector} .blocklyEditableText:not(.editing):hover>rect {`, `${selector} .blocklyEditableField:not(.blocklyEditing):hover>rect {`,
`stroke: #fff;`, `stroke: #fff;`,
`stroke-width: 2;`, `stroke-width: 2;`,
`}`, `}`,

View File

@@ -15,7 +15,6 @@ import type {ExternalValueInput} from '../measurables/external_value_input.js';
import type {Field} from '../measurables/field.js'; import type {Field} from '../measurables/field.js';
import type {Icon} from '../measurables/icon.js'; import type {Icon} from '../measurables/icon.js';
import type {InlineInput} from '../measurables/inline_input.js'; import type {InlineInput} from '../measurables/inline_input.js';
import type {PreviousConnection} from '../measurables/previous_connection.js';
import type {Row} from '../measurables/row.js'; import type {Row} from '../measurables/row.js';
import {Types} from '../measurables/types.js'; import {Types} from '../measurables/types.js';
import type {ConstantProvider, Notch, PuzzleTab} from './constants.js'; import type {ConstantProvider, Notch, PuzzleTab} from './constants.js';
@@ -116,13 +115,8 @@ export class Drawer {
this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topLeft; this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topLeft;
} else if (Types.isRightRoundedCorner(elem)) { } else if (Types.isRightRoundedCorner(elem)) {
this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topRight; this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topRight;
} else if ( } else if (Types.isPreviousConnection(elem)) {
Types.isPreviousConnection(elem) && this.outlinePath_ += (elem.shape as Notch).pathLeft;
elem instanceof Connection
) {
this.outlinePath_ += (
(elem as PreviousConnection).shape as Notch
).pathLeft;
} else if (Types.isHat(elem)) { } else if (Types.isHat(elem)) {
this.outlinePath_ += this.constants_.START_HAT.path; this.outlinePath_ += this.constants_.START_HAT.path;
} else if (Types.isSpacer(elem)) { } else if (Types.isSpacer(elem)) {
@@ -217,7 +211,7 @@ export class Drawer {
let rightCornerYOffset = 0; let rightCornerYOffset = 0;
let outlinePath = ''; let outlinePath = '';
for (let i = elems.length - 1, elem; (elem = elems[i]); i--) { for (let i = elems.length - 1, elem; (elem = elems[i]); i--) {
if (Types.isNextConnection(elem) && elem instanceof Connection) { if (Types.isNextConnection(elem)) {
outlinePath += (elem.shape as Notch).pathRight; outlinePath += (elem.shape as Notch).pathRight;
} else if (Types.isLeftSquareCorner(elem)) { } else if (Types.isLeftSquareCorner(elem)) {
outlinePath += svgPaths.lineOnAxis('H', bottomRow.xPos); outlinePath += svgPaths.lineOnAxis('H', bottomRow.xPos);
@@ -269,9 +263,9 @@ export class Drawer {
for (let i = 0, row; (row = this.info_.rows[i]); i++) { for (let i = 0, row; (row = this.info_.rows[i]); i++) {
for (let j = 0, elem; (elem = row.elements[j]); j++) { for (let j = 0, elem; (elem = row.elements[j]); j++) {
if (Types.isInlineInput(elem)) { if (Types.isInlineInput(elem)) {
this.drawInlineInput_(elem as InlineInput); this.drawInlineInput_(elem);
} else if (Types.isIcon(elem) || Types.isField(elem)) { } else if (Types.isIcon(elem) || Types.isField(elem)) {
this.layoutField_(elem as Field | Icon); this.layoutField_(elem);
} }
} }
} }
@@ -295,13 +289,13 @@ export class Drawer {
} }
if (Types.isIcon(fieldInfo)) { if (Types.isIcon(fieldInfo)) {
const icon = (fieldInfo as Icon).icon; const icon = fieldInfo.icon;
icon.setOffsetInBlock(new Coordinate(xPos, yPos)); icon.setOffsetInBlock(new Coordinate(xPos, yPos));
if (this.info_.isInsertionMarker) { if (this.info_.isInsertionMarker) {
icon.hideForInsertionMarker(); icon.hideForInsertionMarker();
} }
} else { } else {
const svgGroup = (fieldInfo as Field).field.getSvgRoot()!; const svgGroup = fieldInfo.field.getSvgRoot()!;
svgGroup.setAttribute( svgGroup.setAttribute(
'transform', 'transform',
'translate(' + xPos + ',' + yPos + ')' + scale, 'translate(' + xPos + ',' + yPos + ')' + scale,

View File

@@ -49,21 +49,6 @@ export interface IPathObject {
*/ */
setPath(pathString: string): void; setPath(pathString: string): void;
/**
* Apply the stored colours to the block's path, taking into account whether
* the paths belong to a shadow block.
*
* @param block The source block.
*/
applyColour(block: BlockSvg): void;
/**
* Update the style.
*
* @param blockStyle The block style to use.
*/
setStyle(blockStyle: BlockStyle): void;
/** /**
* Flip the SVG paths in RTL. * Flip the SVG paths in RTL.
*/ */
@@ -130,8 +115,23 @@ export interface IPathObject {
rtl: boolean, rtl: boolean,
): void; ): void;
/**
* Apply the stored colours to the block's path, taking into account whether
* the paths belong to a shadow block.
*
* @param block The source block.
*/
applyColour?(block: BlockSvg): void;
/** /**
* Removes any highlight associated with the given connection, if it exists. * Removes any highlight associated with the given connection, if it exists.
*/ */
removeConnectionHighlight?(connection: RenderedConnection): void; removeConnectionHighlight?(connection: RenderedConnection): void;
/**
* Update the style.
*
* @param blockStyle The block style to use.
*/
setStyle?(blockStyle: BlockStyle): void;
} }

View File

@@ -231,7 +231,6 @@ export class RenderInfo {
if (hasHat) { if (hasHat) {
const hat = new Hat(this.constants_); const hat = new Hat(this.constants_);
this.topRow.elements.push(hat); this.topRow.elements.push(hat);
this.topRow.capline = hat.ascenderHeight;
} else if (hasPrevious) { } else if (hasPrevious) {
this.topRow.hasPreviousConnection = true; this.topRow.hasPreviousConnection = true;
this.topRow.connection = new PreviousConnection( this.topRow.connection = new PreviousConnection(
@@ -458,6 +457,11 @@ export class RenderInfo {
} }
} }
// Don't add padding after zero-width fields.
if (prev && Types.isField(prev) && prev.width === 0) {
return this.constants_.NO_PADDING;
}
return this.constants_.MEDIUM_PADDING; return this.constants_.MEDIUM_PADDING;
} }
@@ -672,20 +676,17 @@ export class RenderInfo {
return row.yPos + elem.height / 2; return row.yPos + elem.height / 2;
} }
if (Types.isBottomRow(row)) { if (Types.isBottomRow(row)) {
const bottomRow = row as BottomRow; const baseline = row.yPos + row.height - row.descenderHeight;
const baseline =
bottomRow.yPos + bottomRow.height - bottomRow.descenderHeight;
if (Types.isNextConnection(elem)) { if (Types.isNextConnection(elem)) {
return baseline + elem.height / 2; return baseline + elem.height / 2;
} }
return baseline - elem.height / 2; return baseline - elem.height / 2;
} }
if (Types.isTopRow(row)) { if (Types.isTopRow(row)) {
const topRow = row as TopRow;
if (Types.isHat(elem)) { if (Types.isHat(elem)) {
return topRow.capline - elem.height / 2; return row.capline - elem.height / 2;
} }
return topRow.capline + elem.height / 2; return row.capline + elem.height / 2;
} }
return row.yPos + row.height / 2; return row.yPos + row.height / 2;
} }

View File

@@ -65,6 +65,8 @@ export class PathObject implements IPathObject {
{'class': 'blocklyPath'}, {'class': 'blocklyPath'},
this.svgRoot, this.svgRoot,
); );
this.setClass_('blocklyBlock', true);
} }
/** /**
@@ -167,14 +169,12 @@ export class PathObject implements IPathObject {
* *
* @param enable True if highlighted. * @param enable True if highlighted.
*/ */
updateHighlighted(enable: boolean) { updateHighlighted(enable: boolean) {
if (enable) { if (enable) {
this.svgPath.setAttribute( this.setClass_('blocklyHighlighted', true);
'filter',
'url(#' + this.constants.embossFilterId + ')',
);
} else { } else {
this.svgPath.setAttribute('filter', 'none'); this.setClass_('blocklyHighlighted', false);
} }
} }
@@ -185,8 +185,11 @@ export class PathObject implements IPathObject {
*/ */
protected updateShadow_(shadow: boolean) { protected updateShadow_(shadow: boolean) {
if (shadow) { if (shadow) {
this.setClass_('blocklyShadow', true);
this.svgPath.setAttribute('stroke', 'none'); this.svgPath.setAttribute('stroke', 'none');
this.svgPath.setAttribute('fill', this.style.colourSecondary); this.svgPath.setAttribute('fill', this.style.colourSecondary);
} else {
this.setClass_('blocklyShadow', false);
} }
} }
@@ -197,12 +200,6 @@ export class PathObject implements IPathObject {
*/ */
protected updateDisabled_(disabled: boolean) { protected updateDisabled_(disabled: boolean) {
this.setClass_('blocklyDisabled', disabled); this.setClass_('blocklyDisabled', disabled);
if (disabled) {
this.svgPath.setAttribute(
'fill',
'url(#' + this.constants.disabledPatternId + ')',
);
}
} }
/** /**

View File

@@ -10,15 +10,9 @@ import type {Block} from '../../block.js';
import type {BlockSvg} from '../../block_svg.js'; import type {BlockSvg} from '../../block_svg.js';
import {Connection} from '../../connection.js'; import {Connection} from '../../connection.js';
import {ConnectionType} from '../../connection_type.js'; import {ConnectionType} from '../../connection_type.js';
import {
InsertionMarkerManager,
PreviewType,
} from '../../insertion_marker_manager.js';
import type {IRegistrable} from '../../interfaces/i_registrable.js'; import type {IRegistrable} from '../../interfaces/i_registrable.js';
import type {Marker} from '../../keyboard_nav/marker.js'; import type {Marker} from '../../keyboard_nav/marker.js';
import type {RenderedConnection} from '../../rendered_connection.js';
import type {BlockStyle, Theme} from '../../theme.js'; import type {BlockStyle, Theme} from '../../theme.js';
import * as deprecation from '../../utils/deprecation.js';
import type {WorkspaceSvg} from '../../workspace_svg.js'; import type {WorkspaceSvg} from '../../workspace_svg.js';
import {ConstantProvider} from './constants.js'; import {ConstantProvider} from './constants.js';
import {Drawer} from './drawer.js'; import {Drawer} from './drawer.js';
@@ -79,17 +73,27 @@ export class Renderer implements IRegistrable {
/** /**
* Create any DOM elements that this renderer needs. * Create any DOM elements that this renderer needs.
* If you need to create additional DOM elements, override the * If you need to create additional DOM elements, override the
* {@link ConstantProvider#createDom} method instead. * {@link blockRendering#ConstantProvider.createDom} method instead.
* *
* @param svg The root of the workspace's SVG. * @param svg The root of the workspace's SVG.
* @param theme The workspace theme object. * @param theme The workspace theme object.
* @param injectionDivIfIsParent The div containing the parent workspace and
* all related workspaces and block containers, if this renderer is for the
* parent workspace. CSS variables representing SVG patterns will be scoped
* to this container. Child workspaces should not override the CSS variables
* created by the parent and thus do not need access to the injection div.
* @internal * @internal
*/ */
createDom(svg: SVGElement, theme: Theme) { createDom(
svg: SVGElement,
theme: Theme,
injectionDivIfIsParent?: HTMLElement,
) {
this.constants_.createDom( this.constants_.createDom(
svg, svg,
this.name + '-' + theme.name, this.name + '-' + theme.name,
'.' + this.getClassName() + '.' + theme.getClassName(), '.' + this.getClassName() + '.' + theme.getClassName(),
injectionDivIfIsParent,
); );
} }
@@ -98,8 +102,17 @@ export class Renderer implements IRegistrable {
* *
* @param svg The root of the workspace's SVG. * @param svg The root of the workspace's SVG.
* @param theme The workspace theme object. * @param theme The workspace theme object.
* @param injectionDivIfIsParent The div containing the parent workspace and
* all related workspaces and block containers, if this renderer is for the
* parent workspace. CSS variables representing SVG patterns will be scoped
* to this container. Child workspaces should not override the CSS variables
* created by the parent and thus do not need access to the injection div.
*/ */
refreshDom(svg: SVGElement, theme: Theme) { refreshDom(
svg: SVGElement,
theme: Theme,
injectionDivIfIsParent?: HTMLElement,
) {
const previousConstants = this.getConstants(); const previousConstants = this.getConstants();
previousConstants.dispose(); previousConstants.dispose();
this.constants_ = this.makeConstants_(); this.constants_ = this.makeConstants_();
@@ -110,7 +123,7 @@ export class Renderer implements IRegistrable {
this.constants_.randomIdentifier = previousConstants.randomIdentifier; this.constants_.randomIdentifier = previousConstants.randomIdentifier;
this.constants_.setTheme(theme); this.constants_.setTheme(theme);
this.constants_.init(); this.constants_.init();
this.createDom(svg, theme); this.createDom(svg, theme, injectionDivIfIsParent);
} }
/** /**
@@ -223,49 +236,6 @@ export class Renderer implements IRegistrable {
); );
} }
/**
* Chooses a connection preview method based on the available connection, the
* current dragged connection, and the block being dragged.
*
* @param closest The available connection.
* @param local The connection currently being dragged.
* @param topBlock The block currently being dragged.
* @returns The preview type to display.
*
* @deprecated v10 - This function is no longer respected. A custom
* IConnectionPreviewer may be able to fulfill the functionality.
*/
getConnectionPreviewMethod(
closest: RenderedConnection,
local: RenderedConnection,
topBlock: BlockSvg,
): PreviewType {
deprecation.warn(
'getConnectionPreviewMethod',
'v10',
'v12',
'an IConnectionPreviewer, if it fulfills your use case.',
);
if (
local.type === ConnectionType.OUTPUT_VALUE ||
local.type === ConnectionType.PREVIOUS_STATEMENT
) {
if (
!closest.isConnected() ||
this.orphanCanConnectAtEnd(
topBlock,
closest.targetBlock() as BlockSvg,
local.type,
)
) {
return InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER;
}
return InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE;
}
return InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER;
}
/** /**
* Render the block. * Render the block.
* *

View File

@@ -100,7 +100,7 @@ export class Drawer extends BaseDrawer {
} }
override drawInlineInput_(input: InlineInput) { override drawInlineInput_(input: InlineInput) {
this.highlighter_.drawInlineInput(input as InlineInput); this.highlighter_.drawInlineInput(input);
super.drawInlineInput_(input); super.drawInlineInput_(input);
} }

View File

@@ -14,13 +14,9 @@ import {StatementInput} from '../../inputs/statement_input.js';
import {ValueInput} from '../../inputs/value_input.js'; import {ValueInput} from '../../inputs/value_input.js';
import {RenderInfo as BaseRenderInfo} from '../common/info.js'; import {RenderInfo as BaseRenderInfo} from '../common/info.js';
import type {Measurable} from '../measurables/base.js'; import type {Measurable} from '../measurables/base.js';
import type {BottomRow} from '../measurables/bottom_row.js';
import {ExternalValueInput} from '../measurables/external_value_input.js'; import {ExternalValueInput} from '../measurables/external_value_input.js';
import type {Field} from '../measurables/field.js';
import {InRowSpacer} from '../measurables/in_row_spacer.js'; import {InRowSpacer} from '../measurables/in_row_spacer.js';
import type {InputRow} from '../measurables/input_row.js';
import type {Row} from '../measurables/row.js'; import type {Row} from '../measurables/row.js';
import type {TopRow} from '../measurables/top_row.js';
import {Types} from '../measurables/types.js'; import {Types} from '../measurables/types.js';
import type {ConstantProvider} from './constants.js'; import type {ConstantProvider} from './constants.js';
import {InlineInput} from './measurables/inline_input.js'; import {InlineInput} from './measurables/inline_input.js';
@@ -150,7 +146,7 @@ export class RenderInfo extends BaseRenderInfo {
override getInRowSpacing_(prev: Measurable | null, next: Measurable | null) { override getInRowSpacing_(prev: Measurable | null, next: Measurable | null) {
if (!prev) { if (!prev) {
// Between an editable field and the beginning of the row. // Between an editable field and the beginning of the row.
if (next && Types.isField(next) && (next as Field).isEditable) { if (next && Types.isField(next) && next.isEditable) {
return this.constants_.MEDIUM_PADDING; return this.constants_.MEDIUM_PADDING;
} }
// Inline input at the beginning of the row. // Inline input at the beginning of the row.
@@ -167,7 +163,10 @@ export class RenderInfo extends BaseRenderInfo {
// Spacing between a non-input and the end of the row or a statement input. // Spacing between a non-input and the end of the row or a statement input.
if (!Types.isInput(prev) && (!next || Types.isStatementInput(next))) { if (!Types.isInput(prev) && (!next || Types.isStatementInput(next))) {
// Between an editable field and the end of the row. // Between an editable field and the end of the row.
if (Types.isField(prev) && (prev as Field).isEditable) { if (Types.isField(prev) && prev.isEditable) {
if (prev.width === 0) {
return this.constants_.NO_PADDING;
}
return this.constants_.MEDIUM_PADDING; return this.constants_.MEDIUM_PADDING;
} }
// Padding at the end of an icon-only row to make the block shape clearer. // Padding at the end of an icon-only row to make the block shape clearer.
@@ -208,7 +207,7 @@ export class RenderInfo extends BaseRenderInfo {
// Spacing between a non-input and an input. // Spacing between a non-input and an input.
if (!Types.isInput(prev) && next && Types.isInput(next)) { if (!Types.isInput(prev) && next && Types.isInput(next)) {
// Between an editable field and an input. // Between an editable field and an input.
if (Types.isField(prev) && (prev as Field).isEditable) { if (Types.isField(prev) && prev.isEditable) {
if (Types.isInlineInput(next)) { if (Types.isInlineInput(next)) {
return this.constants_.SMALL_PADDING; return this.constants_.SMALL_PADDING;
} else if (Types.isExternalInput(next)) { } else if (Types.isExternalInput(next)) {
@@ -233,7 +232,7 @@ export class RenderInfo extends BaseRenderInfo {
// Spacing between an inline input and a field. // Spacing between an inline input and a field.
if (Types.isInlineInput(prev) && next && Types.isField(next)) { if (Types.isInlineInput(prev) && next && Types.isField(next)) {
// Editable field after inline input. // Editable field after inline input.
if ((next as Field).isEditable) { if (next.isEditable) {
return this.constants_.MEDIUM_PADDING; return this.constants_.MEDIUM_PADDING;
} else { } else {
// Noneditable field after inline input. // Noneditable field after inline input.
@@ -278,8 +277,11 @@ export class RenderInfo extends BaseRenderInfo {
Types.isField(prev) && Types.isField(prev) &&
next && next &&
Types.isField(next) && Types.isField(next) &&
(prev as Field).isEditable === (next as Field).isEditable prev.isEditable === next.isEditable
) { ) {
if (prev.width === 0) {
return this.constants_.NO_PADDING;
}
return this.constants_.LARGE_PADDING; return this.constants_.LARGE_PADDING;
} }
@@ -323,20 +325,17 @@ export class RenderInfo extends BaseRenderInfo {
return row.yPos + elem.height / 2; return row.yPos + elem.height / 2;
} }
if (Types.isBottomRow(row)) { if (Types.isBottomRow(row)) {
const bottomRow = row as BottomRow; const baseline = row.yPos + row.height - row.descenderHeight;
const baseline =
bottomRow.yPos + bottomRow.height - bottomRow.descenderHeight;
if (Types.isNextConnection(elem)) { if (Types.isNextConnection(elem)) {
return baseline + elem.height / 2; return baseline + elem.height / 2;
} }
return baseline - elem.height / 2; return baseline - elem.height / 2;
} }
if (Types.isTopRow(row)) { if (Types.isTopRow(row)) {
const topRow = row as TopRow;
if (Types.isHat(elem)) { if (Types.isHat(elem)) {
return topRow.capline - elem.height / 2; return row.capline - elem.height / 2;
} }
return topRow.capline + elem.height / 2; return row.capline + elem.height / 2;
} }
let result = row.yPos; let result = row.yPos;
@@ -370,7 +369,7 @@ export class RenderInfo extends BaseRenderInfo {
rowNextRightEdges.set(row, nextRightEdge); rowNextRightEdges.set(row, nextRightEdge);
if (Types.isInputRow(row)) { if (Types.isInputRow(row)) {
if (row.hasStatement) { if (row.hasStatement) {
this.alignStatementRow_(row as InputRow); this.alignStatementRow_(row);
} }
if ( if (
prevInput && prevInput &&

View File

@@ -102,14 +102,10 @@ export class PathObject extends BasePathObject {
} }
override updateHighlighted(highlighted: boolean) { override updateHighlighted(highlighted: boolean) {
super.updateHighlighted(highlighted);
if (highlighted) { if (highlighted) {
this.svgPath.setAttribute(
'filter',
'url(#' + this.constants.embossFilterId + ')',
);
this.svgPathLight.style.display = 'none'; this.svgPathLight.style.display = 'none';
} else { } else {
this.svgPath.setAttribute('filter', 'none');
this.svgPathLight.style.display = 'inline'; this.svgPathLight.style.display = 'inline';
} }
} }

View File

@@ -49,8 +49,12 @@ export class Renderer extends BaseRenderer {
this.highlightConstants.init(); this.highlightConstants.init();
} }
override refreshDom(svg: SVGElement, theme: Theme) { override refreshDom(
super.refreshDom(svg, theme); svg: SVGElement,
theme: Theme,
injectionDiv: HTMLElement,
) {
super.refreshDom(svg, theme, injectionDiv);
this.getHighlightConstants().init(); this.getHighlightConstants().init();
} }

View File

@@ -15,6 +15,14 @@ import {Types} from './types.js';
* row. * row.
*/ */
export class InRowSpacer extends Measurable { export class InRowSpacer extends Measurable {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private inRowSpacer: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
* @param width The width of the spacer. * @param width The width of the spacer.

View File

@@ -7,10 +7,7 @@
// Former goog.module ID: Blockly.blockRendering.InputRow // Former goog.module ID: Blockly.blockRendering.InputRow
import type {ConstantProvider} from '../common/constants.js'; import type {ConstantProvider} from '../common/constants.js';
import {ExternalValueInput} from './external_value_input.js';
import {InputConnection} from './input_connection.js';
import {Row} from './row.js'; import {Row} from './row.js';
import {StatementInput} from './statement_input.js';
import {Types} from './types.js'; import {Types} from './types.js';
/** /**
@@ -40,12 +37,11 @@ export class InputRow extends Row {
for (let i = 0; i < this.elements.length; i++) { for (let i = 0; i < this.elements.length; i++) {
const elem = this.elements[i]; const elem = this.elements[i];
this.width += elem.width; this.width += elem.width;
if (Types.isInput(elem) && elem instanceof InputConnection) { if (Types.isInput(elem)) {
if (Types.isStatementInput(elem) && elem instanceof StatementInput) { if (Types.isStatementInput(elem)) {
connectedBlockWidths += elem.connectedBlockWidth; connectedBlockWidths += elem.connectedBlockWidth;
} else if ( } else if (
Types.isExternalInput(elem) && Types.isExternalInput(elem) &&
elem instanceof ExternalValueInput &&
elem.connectedBlockWidth !== 0 elem.connectedBlockWidth !== 0
) { ) {
connectedBlockWidths += connectedBlockWidths +=

View File

@@ -15,6 +15,14 @@ import {Types} from './types.js';
* collapsed block takes up during rendering. * collapsed block takes up during rendering.
*/ */
export class JaggedEdge extends Measurable { export class JaggedEdge extends Measurable {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private jaggedEdge: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
*/ */

View File

@@ -16,6 +16,14 @@ import {Types} from './types.js';
* up during rendering. * up during rendering.
*/ */
export class NextConnection extends Connection { export class NextConnection extends Connection {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private nextConnection: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
* @param connectionModel The connection object on the block that this * @param connectionModel The connection object on the block that this

View File

@@ -16,6 +16,14 @@ import {Types} from './types.js';
* up during rendering. * up during rendering.
*/ */
export class PreviousConnection extends Connection { export class PreviousConnection extends Connection {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private previousConnection: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
* @param connectionModel The connection object on the block that this * @param connectionModel The connection object on the block that this

View File

@@ -15,6 +15,14 @@ import {Types} from './types.js';
* during rendering. * during rendering.
*/ */
export class RoundCorner extends Measurable { export class RoundCorner extends Measurable {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private roundCorner: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
* @param opt_position The position of this corner. * @param opt_position The position of this corner.

View File

@@ -127,7 +127,7 @@ export class Row {
for (let i = this.elements.length - 1; i >= 0; i--) { for (let i = this.elements.length - 1; i >= 0; i--) {
const elem = this.elements[i]; const elem = this.elements[i];
if (Types.isInput(elem)) { if (Types.isInput(elem)) {
return elem as InputConnection; return elem;
} }
} }
return null; return null;
@@ -166,8 +166,8 @@ export class Row {
getFirstSpacer(): InRowSpacer | null { getFirstSpacer(): InRowSpacer | null {
for (let i = 0; i < this.elements.length; i++) { for (let i = 0; i < this.elements.length; i++) {
const elem = this.elements[i]; const elem = this.elements[i];
if (Types.isSpacer(elem)) { if (Types.isInRowSpacer(elem)) {
return elem as InRowSpacer; return elem;
} }
} }
return null; return null;
@@ -181,8 +181,8 @@ export class Row {
getLastSpacer(): InRowSpacer | null { getLastSpacer(): InRowSpacer | null {
for (let i = this.elements.length - 1; i >= 0; i--) { for (let i = this.elements.length - 1; i >= 0; i--) {
const elem = this.elements[i]; const elem = this.elements[i];
if (Types.isSpacer(elem)) { if (Types.isInRowSpacer(elem)) {
return elem as InRowSpacer; return elem;
} }
} }
return null; return null;

View File

@@ -15,6 +15,14 @@ import {Types} from './types.js';
* during rendering. * during rendering.
*/ */
export class SquareCorner extends Measurable { export class SquareCorner extends Measurable {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private squareCorner: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
* @param opt_position The position of this corner. * @param opt_position The position of this corner.

View File

@@ -16,6 +16,14 @@ import {Types} from './types.js';
* during rendering * during rendering
*/ */
export class StatementInput extends InputConnection { export class StatementInput extends InputConnection {
// This field exists solely to structurally distinguish this type from other
// Measurable subclasses. Because this class otherwise has the same fields as
// Measurable, and Typescript doesn't support nominal typing, Typescript will
// consider it and other subclasses in the same situation as being of the same
// type, even if typeguards are used, which could result in Typescript typing
// objects of this class as `never`.
private statementInput: undefined;
/** /**
* @param constants The rendering constants provider. * @param constants The rendering constants provider.
* @param input The statement input to measure and store information for. * @param input The statement input to measure and store information for.

View File

@@ -8,7 +8,6 @@
import type {BlockSvg} from '../../block_svg.js'; import type {BlockSvg} from '../../block_svg.js';
import type {ConstantProvider} from '../common/constants.js'; import type {ConstantProvider} from '../common/constants.js';
import {Hat} from './hat.js';
import type {PreviousConnection} from './previous_connection.js'; import type {PreviousConnection} from './previous_connection.js';
import {Row} from './row.js'; import {Row} from './row.js';
import {Types} from './types.js'; import {Types} from './types.js';
@@ -85,7 +84,7 @@ export class TopRow extends Row {
const elem = this.elements[i]; const elem = this.elements[i];
width += elem.width; width += elem.width;
if (!Types.isSpacer(elem)) { if (!Types.isSpacer(elem)) {
if (Types.isHat(elem) && elem instanceof Hat) { if (Types.isHat(elem)) {
ascenderHeight = Math.max(ascenderHeight, elem.ascenderHeight); ascenderHeight = Math.max(ascenderHeight, elem.ascenderHeight);
} else { } else {
height = Math.max(height, elem.height); height = Math.max(height, elem.height);

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