release: Merge branch 'rc/v11.0.0' into develop

release: Merge branch 'rc/v11.0.0' into develop
This commit is contained in:
Maribeth Bottorff
2024-05-13 11:34:40 -07:00
committed by GitHub
214 changed files with 6706 additions and 12501 deletions

View File

@@ -6,7 +6,6 @@
// Former goog.module ID: Blockly.libraryBlocks
import * as colour from './colour.js';
import * as lists from './lists.js';
import * as logic from './logic.js';
import * as loops from './loops.js';
@@ -18,7 +17,6 @@ import * as variablesDynamic from './variables_dynamic.js';
import type {BlockDefinition} from '../core/blocks.js';
export {
colour,
lists,
logic,
loops,
@@ -35,7 +33,6 @@ export {
*/
export const blocks: {[key: string]: BlockDefinition} = Object.assign(
{},
colour.blocks,
lists.blocks,
logic.blocks,
loops.blocks,

View File

@@ -1,112 +0,0 @@
/**
* @license
* Copyright 2012 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.libraryBlocks.colour
import {
createBlockDefinitionsFromJsonArray,
defineBlocks,
} from '../core/common.js';
import '../core/field_colour.js';
/**
* A dictionary of the block definitions provided by this module.
*/
export const blocks = createBlockDefinitionsFromJsonArray([
// Block for colour picker.
{
'type': 'colour_picker',
'message0': '%1',
'args0': [
{
'type': 'field_colour',
'name': 'COLOUR',
'colour': '#ff0000',
},
],
'output': 'Colour',
'helpUrl': '%{BKY_COLOUR_PICKER_HELPURL}',
'style': 'colour_blocks',
'tooltip': '%{BKY_COLOUR_PICKER_TOOLTIP}',
'extensions': ['parent_tooltip_when_inline'],
},
// Block for random colour.
{
'type': 'colour_random',
'message0': '%{BKY_COLOUR_RANDOM_TITLE}',
'output': 'Colour',
'helpUrl': '%{BKY_COLOUR_RANDOM_HELPURL}',
'style': 'colour_blocks',
'tooltip': '%{BKY_COLOUR_RANDOM_TOOLTIP}',
},
// Block for composing a colour from RGB components.
{
'type': 'colour_rgb',
'message0':
'%{BKY_COLOUR_RGB_TITLE} %{BKY_COLOUR_RGB_RED} %1 %{BKY_COLOUR_RGB_GREEN} %2 %{BKY_COLOUR_RGB_BLUE} %3',
'args0': [
{
'type': 'input_value',
'name': 'RED',
'check': 'Number',
'align': 'RIGHT',
},
{
'type': 'input_value',
'name': 'GREEN',
'check': 'Number',
'align': 'RIGHT',
},
{
'type': 'input_value',
'name': 'BLUE',
'check': 'Number',
'align': 'RIGHT',
},
],
'output': 'Colour',
'helpUrl': '%{BKY_COLOUR_RGB_HELPURL}',
'style': 'colour_blocks',
'tooltip': '%{BKY_COLOUR_RGB_TOOLTIP}',
},
// Block for blending two colours together.
{
'type': 'colour_blend',
'message0':
'%{BKY_COLOUR_BLEND_TITLE} %{BKY_COLOUR_BLEND_COLOUR1} ' +
'%1 %{BKY_COLOUR_BLEND_COLOUR2} %2 %{BKY_COLOUR_BLEND_RATIO} %3',
'args0': [
{
'type': 'input_value',
'name': 'COLOUR1',
'check': 'Colour',
'align': 'RIGHT',
},
{
'type': 'input_value',
'name': 'COLOUR2',
'check': 'Colour',
'align': 'RIGHT',
},
{
'type': 'input_value',
'name': 'RATIO',
'check': 'Number',
'align': 'RIGHT',
},
],
'output': 'Colour',
'helpUrl': '%{BKY_COLOUR_BLEND_HELPURL}',
'style': 'colour_blocks',
'tooltip': '%{BKY_COLOUR_BLEND_TOOLTIP}',
},
]);
// Register provided blocks.
defineBlocks(blocks);

View File

@@ -20,6 +20,7 @@ import {
createBlockDefinitionsFromJsonArray,
defineBlocks,
} from '../core/common.js';
import * as eventUtils from '../core/events/utils.js';
import '../core/field_dropdown.js';
import '../core/field_label.js';
import '../core/field_number.js';
@@ -334,6 +335,11 @@ export type ControlFlowInLoopBlock = Block & ControlFlowInLoopMixin;
interface ControlFlowInLoopMixin extends ControlFlowInLoopMixinType {}
type ControlFlowInLoopMixinType = typeof CONTROL_FLOW_IN_LOOP_CHECK_MIXIN;
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the block is only valid inside of a loop.
*/
const CONTROL_FLOW_NOT_IN_LOOP_DISABLED_REASON = 'CONTROL_FLOW_NOT_IN_LOOP';
/**
* This mixin adds a check to make sure the 'controls_flow_statements' block
* is contained in a loop. Otherwise a warning is added to the block.
@@ -365,19 +371,30 @@ const CONTROL_FLOW_IN_LOOP_CHECK_MIXIN = {
// Don't change state if:
// * It's at the start of a drag.
// * It's not a move event.
if (!ws.isDragging || ws.isDragging() || e.type !== Events.BLOCK_MOVE) {
if (
!ws.isDragging ||
ws.isDragging() ||
(e.type !== Events.BLOCK_MOVE && e.type !== Events.BLOCK_CREATE)
) {
return;
}
const enabled = !!this.getSurroundLoop();
this.setWarningText(
enabled ? null : Msg['CONTROLS_FLOW_STATEMENTS_WARNING'],
);
if (!this.isInFlyout) {
const group = Events.getGroup();
// Makes it so the move and the disable event get undone together.
Events.setGroup(e.group);
this.setEnabled(enabled);
Events.setGroup(group);
try {
// There is no need to record the enable/disable change on the undo/redo
// list since the change will be automatically recreated when replayed.
eventUtils.setRecordUndo(false);
this.setDisabledReason(
!enabled,
CONTROL_FLOW_NOT_IN_LOOP_DISABLED_REASON,
);
} finally {
eventUtils.setRecordUndo(true);
}
}
},
};

View File

@@ -14,7 +14,7 @@ import * as Xml from '../core/xml.js';
import * as fieldRegistry from '../core/field_registry.js';
import * as xmlUtils from '../core/utils/xml.js';
import type {Abstract as AbstractEvent} from '../core/events/events_abstract.js';
import {Align} from '../core/inputs/input.js';
import {Align} from '../core/inputs/align.js';
import type {Block} from '../core/block.js';
import type {BlockSvg} from '../core/block_svg.js';
import type {BlockCreate} from '../core/events/events_block_create.js';
@@ -25,6 +25,7 @@ import type {
ContextMenuOption,
LegacyContextMenuOption,
} from '../core/contextmenu_registry.js';
import * as eventUtils from '../core/events/utils.js';
import {FieldCheckbox} from '../core/field_checkbox.js';
import {FieldLabel} from '../core/field_label.js';
import {FieldTextInput} from '../core/field_textinput.js';
@@ -38,6 +39,7 @@ import {config} from '../core/config.js';
import {defineBlocks} from '../core/common.js';
import '../core/icons/comment_icon.js';
import '../core/icons/warning_icon.js';
import * as common from '../core/common.js';
/** A dictionary of the block definitions provided by this module. */
export const blocks: {[key: string]: BlockDefinition} = {};
@@ -753,7 +755,6 @@ interface CallMixin extends CallMixinType {
defType_: string;
quarkIds_: string[] | null;
quarkConnections_: {[id: string]: Connection};
previousEnabledState_: boolean;
}
type CallMixinType = typeof PROCEDURE_CALL_COMMON;
@@ -763,6 +764,13 @@ type CallExtraState = {
params?: string[];
};
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the block's corresponding procedure definition is disabled.
*/
const DISABLED_PROCEDURE_DEFINITION_DISABLED_REASON =
'DISABLED_PROCEDURE_DEFINITION';
/**
* Common properties for the procedure_callnoreturn and
* procedure_callreturn blocks.
@@ -921,10 +929,9 @@ const PROCEDURE_CALL_COMMON = {
type: 'field_label',
text: this.arguments_[i],
}) as FieldLabel;
const input = this.appendValueInput('ARG' + i)
this.appendValueInput('ARG' + i)
.setAlign(Align.RIGHT)
.appendField(newField, 'ARGNAME' + i);
input.init();
}
}
// Remove deleted inputs.
@@ -937,7 +944,6 @@ const PROCEDURE_CALL_COMMON = {
if (this.arguments_.length) {
if (!this.getField('WITH')) {
topRow.appendField(Msg['PROCEDURES_CALL_BEFORE_PARAMS'], 'WITH');
topRow.init();
}
} else {
if (this.getField('WITH')) {
@@ -1125,12 +1131,16 @@ const PROCEDURE_CALL_COMMON = {
);
}
Events.setGroup(event.group);
if (blockChangeEvent.newValue) {
this.previousEnabledState_ = this.isEnabled();
this.setEnabled(false);
} else {
this.setEnabled(this.previousEnabledState_);
}
const valid = def.isEnabled();
this.setDisabledReason(
!valid,
DISABLED_PROCEDURE_DEFINITION_DISABLED_REASON,
);
this.setWarningText(
valid
? null
: Msg['PROCEDURES_CALL_DISABLED_DEF_WARNING'].replace('%1', name),
);
Events.setGroup(oldGroup);
}
}
@@ -1159,7 +1169,7 @@ const PROCEDURE_CALL_COMMON = {
const def = Procedures.getDefinition(name, workspace);
if (def) {
(workspace as WorkspaceSvg).centerOnBlock(def.id);
(def as BlockSvg).select();
common.setSelected(def as BlockSvg);
}
},
});
@@ -1182,7 +1192,6 @@ blocks['procedures_callnoreturn'] = {
this.argumentVarModels_ = [];
this.quarkConnections_ = {};
this.quarkIds_ = null;
this.previousEnabledState_ = true;
},
defType_: 'procedures_defnoreturn',
@@ -1203,7 +1212,6 @@ blocks['procedures_callreturn'] = {
this.argumentVarModels_ = [];
this.quarkConnections_ = {};
this.quarkIds_ = null;
this.previousEnabledState_ = true;
},
defType_: 'procedures_defreturn',
@@ -1220,6 +1228,12 @@ interface IfReturnMixin extends IfReturnMixinType {
}
type IfReturnMixinType = typeof PROCEDURES_IFRETURN;
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the block is only valid inside of a procedure body.
*/
const UNPARENTED_IFRETURN_DISABLED_REASON = 'UNPARENTED_IFRETURN';
const PROCEDURES_IFRETURN = {
/**
* Block for conditionally returning a value from a procedure.
@@ -1280,7 +1294,7 @@ const PROCEDURES_IFRETURN = {
if (
((this.workspace as WorkspaceSvg).isDragging &&
(this.workspace as WorkspaceSvg).isDragging()) ||
e.type !== Events.BLOCK_MOVE
(e.type !== Events.BLOCK_MOVE && e.type !== Events.BLOCK_CREATE)
) {
return; // Don't change state at the start of a drag.
}
@@ -1316,12 +1330,16 @@ const PROCEDURES_IFRETURN = {
} else {
this.setWarningText(Msg['PROCEDURES_IFRETURN_WARNING']);
}
if (!this.isInFlyout) {
const group = Events.getGroup();
// Makes it so the move and the disable event get undone together.
Events.setGroup(e.group);
this.setEnabled(legal);
Events.setGroup(group);
try {
// There is no need to record the enable/disable change on the undo/redo
// list since the change will be automatically recreated when replayed.
eventUtils.setRecordUndo(false);
this.setDisabledReason(!legal, UNPARENTED_IFRETURN_DISABLED_REASON);
} finally {
eventUtils.setRecordUndo(true);
}
}
},
/**

View File

@@ -23,7 +23,6 @@ import {
createBlockDefinitionsFromJsonArray,
defineBlocks,
} from '../core/common.js';
import '../core/field_multilineinput.js';
import '../core/field_variable.js';
import {ValueInput} from '../core/inputs/value_input.js';
@@ -48,38 +47,6 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'tooltip': '%{BKY_TEXT_TEXT_TOOLTIP}',
'extensions': ['text_quotes', 'parent_tooltip_when_inline'],
},
{
'type': 'text_multiline',
'message0': '%1 %2',
'args0': [
{
'type': 'field_image',
'src':
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAARCAYAAADpP' +
'U2iAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAdhgAAHYYBXaITgQAAABh0RVh0' +
'U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAP1JREFUOE+Vks0KQUEYhjm' +
'RIja4ABtZ2dm5A3t3Ia6AUm7CylYuQRaUhZSlLZJiQbFAyRnPN33y01HOW08z88' +
'73zpwzM4F3GWOCruvGIE4/rLaV+Nq1hVGMBqzhqlxgCys4wJA65xnogMHsQ5luj' +
'nYHTejBBCK2mE4abjCgMGhNxHgDFWjDSG07kdfVa2pZMf4ZyMAdWmpZMfYOsLiD' +
'MYMjlMB+K613QISRhTnITnsYg5yUd0DETmEoMlkFOeIT/A58iyK5E18BuTBfgYX' +
'fwNJv4P9/oEBerLylOnRhygmGdPpTTBZAPkde61lbQe4moWUvYUZYLfUNftIY4z' +
'wA5X2Z9AYnQrEAAAAASUVORK5CYII=',
'width': 12,
'height': 17,
'alt': '\u00B6',
},
{
'type': 'field_multilinetext',
'name': 'TEXT',
'text': '',
},
],
'output': 'String',
'style': 'text_blocks',
'helpUrl': '%{BKY_TEXT_TEXT_HELPURL}',
'tooltip': '%{BKY_TEXT_TEXT_TOOLTIP}',
'extensions': ['parent_tooltip_when_inline'],
},
{
'type': 'text_join',
'message0': '',

View File

@@ -25,7 +25,9 @@ import {ConnectionType} from './connection_type.js';
import * as constants from './constants.js';
import {DuplicateIconType} from './icons/exceptions.js';
import type {Abstract} from './events/events_abstract.js';
import type {BlockChange} from './events/events_block_change.js';
import type {BlockMove} from './events/events_block_move.js';
import * as deprecation from './utils/deprecation.js';
import * as eventUtils from './events/utils.js';
import * as Extensions from './extensions.js';
import type {Field} from './field.js';
@@ -33,9 +35,8 @@ import * as fieldRegistry from './field_registry.js';
import {Input} from './inputs/input.js';
import {Align} from './inputs/align.js';
import type {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
import type {IDeletable} from './interfaces/i_deletable.js';
import type {IIcon} from './interfaces/i_icon.js';
import {CommentIcon} from './icons/comment_icon.js';
import {type IIcon} from './interfaces/i_icon.js';
import {isCommentIcon} from './interfaces/i_comment_icon.js';
import type {MutatorIcon} from './icons/mutator_icon.js';
import * as Tooltip from './tooltip.js';
import * as arrayUtils from './utils/array.js';
@@ -56,7 +57,7 @@ import {IconType} from './icons/icon_types.js';
* Class for one block.
* Not normally called directly, workspace.newBlock() is preferred.
*/
export class Block implements IASTNodeLocation, IDeletable {
export class Block implements IASTNodeLocation {
/**
* An optional callback method to use whenever the block's parent workspace
* changes. This is usually only called from the constructor, the block type
@@ -167,7 +168,7 @@ export class Block implements IASTNodeLocation, IDeletable {
inputList: Input[] = [];
inputsInline?: boolean;
icons: IIcon[] = [];
private disabled = false;
private disabledReasons = new Set<string>();
tooltip: Tooltip.TipInfo = '';
contextMenu = true;
@@ -189,7 +190,14 @@ export class Block implements IASTNodeLocation, IDeletable {
/**
* Is the current block currently in the process of being disposed?
*/
private disposing = false;
protected disposing = false;
/**
* Has this block been fully initialized? E.g. all fields initailized.
*
* @internal
*/
initialized = false;
private readonly xy_: Coordinate;
isInFlyout: boolean;
@@ -202,7 +210,8 @@ export class Block implements IASTNodeLocation, IDeletable {
/** Name of the type of hat. */
hat?: string;
rendered: boolean | null = null;
/** Is this block a BlockSVG? */
readonly rendered: boolean = false;
/**
* String for block help, or function that returns a URL. Null for no help.
@@ -310,8 +319,8 @@ export class Block implements IASTNodeLocation, IDeletable {
* statement with the previous statement. Otherwise, dispose of all
* children of this block.
*/
dispose(healStack: boolean) {
if (this.isDeadOrDying()) return;
dispose(healStack = false) {
this.disposing = true;
// Dispose of this change listener before unplugging.
// Technically not necessary due to the event firing delay.
@@ -334,15 +343,13 @@ export class Block implements IASTNodeLocation, IDeletable {
* E.g. does not fire events, unplug the block, etc.
*/
protected disposeInternal() {
if (this.isDeadOrDying()) return;
this.disposing = true;
if (this.onchangeWrapper_) {
this.workspace.removeChangeListener(this.onchangeWrapper_);
}
this.workspace.removeTypedBlock(this);
this.workspace.removeBlockById(this.id);
this.disposing = true;
if (typeof this.destroy === 'function') this.destroy();
@@ -372,13 +379,11 @@ export class Block implements IASTNodeLocation, IDeletable {
* change).
*/
initModel() {
if (this.initialized) return;
for (const input of this.inputList) {
for (const field of input.fieldRow) {
if (field.initModel) {
field.initModel();
}
}
input.initModel();
}
this.initialized = true;
}
/**
@@ -559,7 +564,6 @@ export class Block implements IASTNodeLocation, IDeletable {
* connected should not coincidentally line up on screen.
*/
bumpNeighbours() {}
// noop.
/**
* Return the parent block or null if this block is at the top level. The
@@ -1388,32 +1392,89 @@ export class Block implements IASTNodeLocation, IDeletable {
}
/**
* Get whether this block is enabled or not.
* Get whether this block is enabled or not. A block is considered enabled
* if there aren't any reasons why it would be disabled. A block may still
* be disabled for other reasons even if the user attempts to manually
* enable it, such as when the block is in an invalid location.
*
* @returns True if enabled.
*/
isEnabled(): boolean {
return !this.disabled;
return this.disabledReasons.size === 0;
}
/** @deprecated v11 - Get whether the block is manually disabled. */
private get disabled(): boolean {
deprecation.warn(
'disabled',
'v11',
'v12',
'the isEnabled or hasDisabledReason methods of Block',
);
return this.hasDisabledReason(constants.MANUALLY_DISABLED);
}
/** @deprecated v11 - Set whether the block is manually disabled. */
private set disabled(value: boolean) {
deprecation.warn(
'disabled',
'v11',
'v12',
'the setDisabledReason method of Block',
);
this.setDisabledReason(value, constants.MANUALLY_DISABLED);
}
/**
* Set whether the block is enabled or not.
* @deprecated v11 - Set whether the block is manually enabled or disabled.
* The user can toggle whether a block is disabled from a context menu
* option. A block may still be disabled for other reasons even if the user
* attempts to manually enable it, such as when the block is in an invalid
* location. This method is deprecated and setDisabledReason should be used
* instead.
*
* @param enabled True if enabled.
*/
setEnabled(enabled: boolean) {
if (this.isEnabled() !== enabled) {
const oldValue = this.disabled;
this.disabled = !enabled;
eventUtils.fire(
new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
this,
'disabled',
null,
oldValue,
!enabled,
),
);
deprecation.warn(
'setEnabled',
'v11',
'v12',
'the setDisabledReason method of Block',
);
this.setDisabledReason(!enabled, constants.MANUALLY_DISABLED);
}
/**
* Add or remove a reason why the block might be disabled. If a block has
* any reasons to be disabled, then the block itself will be considered
* disabled. A block could be disabled for multiple independent reasons
* simultaneously, such as when the user manually disables it, or the block
* is invalid.
*
* @param disabled If true, then the block should be considered disabled for
* at least the provided reason, otherwise the block is no longer disabled
* for that reason.
* @param reason A language-neutral identifier for a reason why the block
* could be disabled. Call this method again with the same identifier to
* update whether the block is currently disabled for this reason.
*/
setDisabledReason(disabled: boolean, reason: string): void {
if (this.disabledReasons.has(reason) !== disabled) {
if (disabled) {
this.disabledReasons.add(reason);
} else {
this.disabledReasons.delete(reason);
}
const blockChangeEvent = new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
this,
'disabled',
/* name= */ null,
/* oldValue= */ !disabled,
/* newValue= */ disabled,
) as BlockChange;
blockChangeEvent.setDisabledReason(reason);
eventUtils.fire(blockChangeEvent);
}
}
@@ -1426,7 +1487,7 @@ export class Block implements IASTNodeLocation, IDeletable {
getInheritedDisabled(): boolean {
let ancestor = this.getSurroundParent();
while (ancestor) {
if (ancestor.disabled) {
if (!ancestor.isEnabled()) {
return true;
}
ancestor = ancestor.getSurroundParent();
@@ -1435,6 +1496,27 @@ export class Block implements IASTNodeLocation, IDeletable {
return false;
}
/**
* Get whether the block is currently disabled for the provided reason.
*
* @param reason A language-neutral identifier for a reason why the block
* could be disabled.
* @returns Whether the block is disabled for the provided reason.
*/
hasDisabledReason(reason: string): boolean {
return this.disabledReasons.has(reason);
}
/**
* Get a set of reasons why the block is currently disabled, if any. If the
* block is enabled, this set will be empty.
*
* @returns The set of reasons why the block is disabled, if any.
*/
getDisabledReasons(): ReadonlySet<string> {
return this.disabledReasons;
}
/**
* Get whether the block is collapsed or not.
*
@@ -2208,7 +2290,7 @@ export class Block implements IASTNodeLocation, IDeletable {
* @returns Block's comment.
*/
getCommentText(): string | null {
const comment = this.getIcon(CommentIcon.TYPE) as CommentIcon | null;
const comment = this.getIcon(IconType.COMMENT);
return comment?.getText() ?? null;
}
@@ -2218,19 +2300,36 @@ export class Block implements IASTNodeLocation, IDeletable {
* @param text The text, or null to delete.
*/
setCommentText(text: string | null) {
const comment = this.getIcon(CommentIcon.TYPE) as CommentIcon | null;
const comment = this.getIcon(IconType.COMMENT);
const oldText = comment?.getText() ?? null;
if (oldText === text) return;
if (text !== null) {
let comment = this.getIcon(CommentIcon.TYPE) as CommentIcon | undefined;
let comment = this.getIcon(IconType.COMMENT);
if (!comment) {
comment = this.addIcon(new CommentIcon(this));
const commentConstructor = registry.getClass(
registry.Type.ICON,
IconType.COMMENT.toString(),
false,
);
if (!commentConstructor) {
throw new Error(
'No comment icon class is registered, so a comment cannot be set',
);
}
const icon = new commentConstructor(this);
if (!isCommentIcon(icon)) {
throw new Error(
'The class registered as a comment icon does not conform to the ' +
'ICommentIcon interface',
);
}
comment = this.addIcon(icon);
}
eventUtils.disable();
comment.setText(text);
eventUtils.enable();
} else {
this.removeIcon(CommentIcon.TYPE);
this.removeIcon(IconType.COMMENT);
}
eventUtils.fire(

View File

@@ -1,708 +0,0 @@
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Methods for dragging a block visually.
*
* @class
*/
// Former goog.module ID: Blockly.BlockDragger
// Unused import preserved for side-effects. Remove if unneeded.
import './events/events_block_drag.js';
import * as blockAnimation from './block_animations.js';
import type {BlockSvg} from './block_svg.js';
import * as bumpObjects from './bump_objects.js';
import * as common from './common.js';
import type {BlockMove} from './events/events_block_move.js';
import * as eventUtils from './events/utils.js';
import type {Icon} from './icons/icon.js';
import type {IBlockDragger} from './interfaces/i_block_dragger.js';
import type {IDragTarget} from './interfaces/i_drag_target.js';
import * as registry from './registry.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import {hasBubble} from './interfaces/i_has_bubble.js';
import * as deprecation from './utils/deprecation.js';
import * as layers from './layers.js';
import {ConnectionType, IConnectionPreviewer} from './blockly.js';
import {RenderedConnection} from './rendered_connection.js';
import {config} from './config.js';
import {ComponentManager} from './component_manager.js';
import {IDeleteArea} from './interfaces/i_delete_area.js';
import {Connection} from './connection.js';
import {Block} from './block.js';
import {finishQueuedRenders} from './render_management.js';
/** Represents a nearby valid connection. */
interface ConnectionCandidate {
/** A connection on the dragging stack that is compatible with neighbour. */
local: RenderedConnection;
/** A nearby connection that is compatible with local. */
neighbour: RenderedConnection;
/** The distance between the local connection and the neighbour connection. */
distance: number;
}
/**
* Class for a block dragger. It moves blocks around the workspace when they
* are being dragged by a mouse or touch.
*/
export class BlockDragger implements IBlockDragger {
/** The top block in the stack that is being dragged. */
protected draggingBlock_: BlockSvg;
protected connectionPreviewer: IConnectionPreviewer;
/** The workspace on which the block is being dragged. */
protected workspace_: WorkspaceSvg;
/** Which drag area the mouse pointer is over, if any. */
private dragTarget_: IDragTarget | null = null;
private connectionCandidate: ConnectionCandidate | null = null;
/** Whether the block would be deleted if dropped immediately. */
protected wouldDeleteBlock_ = false;
protected startXY_: Coordinate;
/** The parent block at the start of the drag. */
private startParentConn: RenderedConnection | null = null;
/**
* The child block at the start of the drag. Only gets set if
* `healStack` is true.
*/
private startChildConn: RenderedConnection | null = null;
/**
* @deprecated To be removed in v11. Updating icons is now handled by the
* block's `moveDuringDrag` method.
*/
protected dragIconData_: IconPositionData[] = [];
/**
* @param block The block to drag.
* @param workspace The workspace to drag on.
*/
constructor(block: BlockSvg, workspace: WorkspaceSvg) {
this.draggingBlock_ = block;
this.workspace_ = workspace;
const previewerConstructor = registry.getClassFromOptions(
registry.Type.CONNECTION_PREVIEWER,
this.workspace_.options,
);
this.connectionPreviewer = new previewerConstructor!(block);
/**
* The location of the top left corner of the dragging block at the
* beginning of the drag in workspace coordinates.
*/
this.startXY_ = this.draggingBlock_.getRelativeToSurfaceXY();
this.dragIconData_ = initIconData(block, this.startXY_);
}
/**
* Sever all links from this object.
*
* @internal
*/
dispose() {
this.dragIconData_.length = 0;
this.connectionPreviewer.dispose();
}
/**
* Start dragging a block.
*
* @param currentDragDeltaXY How far the pointer has moved from the position
* at mouse down, in pixel units.
* @param healStack Whether or not to heal the stack after disconnecting.
*/
startDrag(currentDragDeltaXY: Coordinate, healStack: boolean) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.fireDragStartEvent_();
// The z-order of blocks depends on their order in the SVG, so move the
// block being dragged to the front so that it will appear atop other blocks
// in the workspace.
this.draggingBlock_.bringToFront(true);
// During a drag there may be a lot of rerenders, but not field changes.
// Turn the cache on so we don't do spurious remeasures during the drag.
dom.startTextWidthCache();
this.workspace_.setResizesEnabled(false);
blockAnimation.disconnectUiStop();
if (this.shouldDisconnect_(healStack)) {
this.startParentConn =
this.draggingBlock_.outputConnection?.targetConnection ??
this.draggingBlock_.previousConnection?.targetConnection;
if (healStack) {
this.startChildConn =
this.draggingBlock_.nextConnection?.targetConnection;
}
this.disconnectBlock_(healStack, currentDragDeltaXY);
}
this.draggingBlock_.setDragging(true);
this.workspace_.getLayerManager()?.moveToDragLayer(this.draggingBlock_);
}
/**
* Whether or not we should disconnect the block when a drag is started.
*
* @param healStack Whether or not to heal the stack after disconnecting.
* @returns True to disconnect the block, false otherwise.
*/
protected shouldDisconnect_(healStack: boolean): boolean {
return !!(
this.draggingBlock_.getParent() ||
(healStack &&
this.draggingBlock_.nextConnection &&
this.draggingBlock_.nextConnection.targetBlock())
);
}
/**
* Disconnects the block and moves it to a new location.
*
* @param healStack Whether or not to heal the stack after disconnecting.
* @param currentDragDeltaXY How far the pointer has moved from the position
* at mouse down, in pixel units.
*/
protected disconnectBlock_(
healStack: boolean,
currentDragDeltaXY: Coordinate,
) {
this.draggingBlock_.unplug(healStack);
const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
const newLoc = Coordinate.sum(this.startXY_, delta);
this.draggingBlock_.translate(newLoc.x, newLoc.y);
blockAnimation.disconnectUiEffect(this.draggingBlock_);
}
/** Fire a UI event at the start of a block drag. */
protected fireDragStartEvent_() {
const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
this.draggingBlock_,
true,
this.draggingBlock_.getDescendants(false),
);
eventUtils.fire(event);
}
/**
* Execute a step of block dragging, based on the given event. Update the
* display accordingly.
*
* @param e The most recent move event.
* @param delta How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
drag(e: PointerEvent, delta: Coordinate) {
const block = this.draggingBlock_;
this.moveBlock(block, delta);
this.updateDragTargets(e, block);
this.wouldDeleteBlock_ = this.wouldDeleteBlock(e, block, delta);
this.updateCursorDuringBlockDrag_();
this.updateConnectionPreview(block, delta);
}
/**
* @param draggingBlock The block being dragged.
* @param dragDelta How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
private moveBlock(draggingBlock: BlockSvg, dragDelta: Coordinate) {
const delta = this.pixelsToWorkspaceUnits_(dragDelta);
const newLoc = Coordinate.sum(this.startXY_, delta);
draggingBlock.moveDuringDrag(newLoc);
}
private updateDragTargets(e: PointerEvent, draggingBlock: BlockSvg) {
const newDragTarget = this.workspace_.getDragTarget(e);
if (this.dragTarget_ !== newDragTarget) {
this.dragTarget_?.onDragExit(draggingBlock);
newDragTarget?.onDragEnter(draggingBlock);
}
newDragTarget?.onDragOver(draggingBlock);
this.dragTarget_ = newDragTarget;
}
/**
* Returns true if we would delete the block if it was dropped at this time,
* false otherwise.
*
* @param e The most recent move event.
* @param draggingBlock The block being dragged.
* @param delta How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
private wouldDeleteBlock(
e: PointerEvent,
draggingBlock: BlockSvg,
delta: Coordinate,
): boolean {
const dragTarget = this.workspace_.getDragTarget(e);
if (!dragTarget) return false;
const componentManager = this.workspace_.getComponentManager();
const isDeleteArea = componentManager.hasCapability(
dragTarget.id,
ComponentManager.Capability.DELETE_AREA,
);
if (!isDeleteArea) return false;
return (dragTarget as IDeleteArea).wouldDelete(
draggingBlock,
!!this.getConnectionCandidate(draggingBlock, delta),
);
}
/**
* @param draggingBlock The block being dragged.
* @param dragDelta How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
private updateConnectionPreview(
draggingBlock: BlockSvg,
dragDelta: Coordinate,
) {
const delta = this.pixelsToWorkspaceUnits_(dragDelta);
const currCandidate = this.connectionCandidate;
const newCandidate = this.getConnectionCandidate(draggingBlock, delta);
if (!newCandidate) {
this.connectionPreviewer.hidePreview();
this.connectionCandidate = null;
return;
}
const candidate =
currCandidate &&
this.currCandidateIsBetter(currCandidate, delta, newCandidate)
? currCandidate
: newCandidate;
this.connectionCandidate = candidate;
const {local, neighbour} = candidate;
if (
(local.type === ConnectionType.OUTPUT_VALUE ||
local.type === ConnectionType.PREVIOUS_STATEMENT) &&
neighbour.isConnected() &&
!neighbour.targetBlock()!.isInsertionMarker() &&
!this.orphanCanConnectAtEnd(
draggingBlock,
neighbour.targetBlock()!,
local.type,
)
) {
this.connectionPreviewer.previewReplacement(
local,
neighbour,
neighbour.targetBlock()!,
);
return;
}
this.connectionPreviewer.previewConnection(local, neighbour);
}
/**
* Returns true if the given orphan block can connect at the end of the
* top block's stack or row, false otherwise.
*/
private orphanCanConnectAtEnd(
topBlock: BlockSvg,
orphanBlock: BlockSvg,
localType: number,
): boolean {
const orphanConnection =
localType === ConnectionType.OUTPUT_VALUE
? orphanBlock.outputConnection
: orphanBlock.previousConnection;
return !!Connection.getConnectionForOrphanedConnection(
topBlock as Block,
orphanConnection as Connection,
);
}
/**
* Returns true if the current candidate is better than the new candidate.
*
* We slightly prefer the current candidate even if it is farther away.
*/
private currCandidateIsBetter(
currCandiate: ConnectionCandidate,
delta: Coordinate,
newCandidate: ConnectionCandidate,
): boolean {
const {local: currLocal, neighbour: currNeighbour} = currCandiate;
const localPos = new Coordinate(currLocal.x, currLocal.y);
const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y);
const distance = Coordinate.distance(
Coordinate.sum(localPos, delta),
neighbourPos,
);
return (
newCandidate.distance > distance - config.currentConnectionPreference
);
}
/**
* Returns the closest valid candidate connection, if one can be found.
*
* Valid neighbour connections are within the configured start radius, with a
* compatible type (input, output, etc) and connection check.
*/
private getConnectionCandidate(
draggingBlock: BlockSvg,
delta: Coordinate,
): ConnectionCandidate | null {
const localConns = this.getLocalConnections(draggingBlock);
let radius = this.connectionCandidate
? config.connectingSnapRadius
: config.snapRadius;
let candidate = null;
for (const conn of localConns) {
const {connection: neighbour, radius: rad} = conn.closest(radius, delta);
if (neighbour) {
candidate = {
local: conn,
neighbour: neighbour,
distance: rad,
};
radius = rad;
}
}
return candidate;
}
/**
* Returns all of the connections we might connect to blocks on the workspace.
*
* Includes any connections on the dragging block, and any last next
* connection on the stack (if one exists).
*/
private getLocalConnections(draggingBlock: BlockSvg): RenderedConnection[] {
const available = draggingBlock.getConnections_(false);
const lastOnStack = draggingBlock.lastConnectionInStack(true);
if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) {
available.push(lastOnStack);
}
return available;
}
/**
* Finish a block drag and put the block back on the workspace.
*
* @param e The pointerup event.
* @param currentDragDeltaXY How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
endDrag(e: PointerEvent, currentDragDeltaXY: Coordinate) {
// Make sure internal state is fresh.
this.drag(e, currentDragDeltaXY);
this.fireDragEndEvent_();
dom.stopTextWidthCache();
blockAnimation.disconnectUiStop();
this.connectionPreviewer.hidePreview();
const preventMove =
!!this.dragTarget_ &&
this.dragTarget_.shouldPreventMove(this.draggingBlock_);
let delta: Coordinate | null = null;
if (!preventMove) {
const newValues = this.getNewLocationAfterDrag_(currentDragDeltaXY);
delta = newValues.delta;
}
if (this.dragTarget_) {
this.dragTarget_.onDrop(this.draggingBlock_);
}
const deleted = this.maybeDeleteBlock_();
if (!deleted) {
// These are expensive and don't need to be done if we're deleting.
this.workspace_
.getLayerManager()
?.moveOffDragLayer(this.draggingBlock_, layers.BLOCK);
this.draggingBlock_.setDragging(false);
if (preventMove) {
this.moveToOriginalPosition();
} else if (delta) {
this.updateBlockAfterMove_();
}
}
// Must dispose after `updateBlockAfterMove_` is called to not break the
// dynamic connections plugin.
this.connectionPreviewer.dispose();
this.workspace_.setResizesEnabled(true);
eventUtils.setGroup(false);
}
/**
* Moves the dragged block back to its original position before the start of
* the drag. Reconnects any parent and child blocks.
*/
private moveToOriginalPosition() {
this.startChildConn?.connect(this.draggingBlock_.nextConnection);
if (this.startParentConn) {
switch (this.startParentConn.type) {
case ConnectionType.INPUT_VALUE:
this.startParentConn.connect(this.draggingBlock_.outputConnection);
break;
case ConnectionType.NEXT_STATEMENT:
this.startParentConn.connect(this.draggingBlock_.previousConnection);
}
} else {
this.draggingBlock_.moveTo(this.startXY_, ['drag']);
// Blocks dragged directly from a flyout may need to be bumped into
// bounds.
bumpObjects.bumpIntoBounds(
this.draggingBlock_.workspace,
this.workspace_.getMetricsManager().getScrollMetrics(true),
this.draggingBlock_,
);
}
}
/**
* Calculates the drag delta and new location values after a block is dragged.
*
* @param currentDragDeltaXY How far the pointer has moved from the start of
* the drag, in pixel units.
* @returns New location after drag. delta is in workspace units. newLocation
* is the new coordinate where the block should end up.
*/
protected getNewLocationAfterDrag_(currentDragDeltaXY: Coordinate): {
delta: Coordinate;
newLocation: Coordinate;
} {
const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
const newLocation = Coordinate.sum(this.startXY_, delta);
return {
delta,
newLocation,
};
}
/**
* May delete the dragging block, if allowed. If `this.wouldDeleteBlock_` is
* not true, the block will not be deleted. This should be called at the end
* of a block drag.
*
* @returns True if the block was deleted.
*/
protected maybeDeleteBlock_(): boolean {
if (this.wouldDeleteBlock_) {
// Fire a move event, so we know where to go back to for an undo.
this.fireMoveEvent_();
this.draggingBlock_.dispose(false, true);
common.draggingConnections.length = 0;
return true;
}
return false;
}
/**
* Updates the necessary information to place a block at a certain location.
*/
protected updateBlockAfterMove_() {
this.fireMoveEvent_();
if (this.connectionCandidate) {
// Applying connections also rerenders the relevant blocks.
this.applyConnections(this.connectionCandidate);
} else {
this.draggingBlock_.queueRender();
}
this.draggingBlock_.scheduleSnapAndBump();
}
private applyConnections(candidate: ConnectionCandidate) {
const {local, neighbour} = candidate;
local.connect(neighbour);
// TODO: We can remove this `rendered` check when we reconcile with v11.
if (this.draggingBlock_.rendered) {
const inferiorConnection = local.isSuperior() ? neighbour : local;
const rootBlock = this.draggingBlock_.getRootBlock();
finishQueuedRenders().then(() => {
blockAnimation.connectionUiEffect(inferiorConnection.getSourceBlock());
// bringToFront is incredibly expensive. Delay until the next frame.
setTimeout(() => {
rootBlock.bringToFront();
}, 0);
});
}
}
/** Fire a UI event at the end of a block drag. */
protected fireDragEndEvent_() {
const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
this.draggingBlock_,
false,
this.draggingBlock_.getDescendants(false),
);
eventUtils.fire(event);
}
/**
* Adds or removes the style of the cursor for the toolbox.
* This is what changes the cursor to display an x when a deletable block is
* held over the toolbox.
*
* @param isEnd True if we are at the end of a drag, false otherwise.
*/
protected updateToolboxStyle_(isEnd: boolean) {
const toolbox = this.workspace_.getToolbox();
if (toolbox) {
const style = this.draggingBlock_.isDeletable()
? 'blocklyToolboxDelete'
: 'blocklyToolboxGrab';
// AnyDuringMigration because: Property 'removeStyle' does not exist on
// type 'IToolbox'.
if (
isEnd &&
typeof (toolbox as AnyDuringMigration).removeStyle === 'function'
) {
// AnyDuringMigration because: Property 'removeStyle' does not exist on
// type 'IToolbox'.
(toolbox as AnyDuringMigration).removeStyle(style);
// AnyDuringMigration because: Property 'addStyle' does not exist on
// type 'IToolbox'.
} else if (
!isEnd &&
typeof (toolbox as AnyDuringMigration).addStyle === 'function'
) {
// AnyDuringMigration because: Property 'addStyle' does not exist on
// type 'IToolbox'.
(toolbox as AnyDuringMigration).addStyle(style);
}
}
}
/** Fire a move event at the end of a block drag. */
protected fireMoveEvent_() {
if (this.draggingBlock_.isDeadOrDying()) return;
const event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(
this.draggingBlock_,
) as BlockMove;
event.setReason(['drag']);
event.oldCoordinate = this.startXY_;
event.recordNew();
eventUtils.fire(event);
}
/**
* Update the cursor (and possibly the trash can lid) to reflect whether the
* dragging block would be deleted if released immediately.
*/
protected updateCursorDuringBlockDrag_() {
this.draggingBlock_.setDeleteStyle(this.wouldDeleteBlock_);
}
/**
* Convert a coordinate object from pixels to workspace units, including a
* correction for mutator workspaces.
* This function does not consider differing origins. It simply scales the
* input's x and y values.
*
* @param pixelCoord A coordinate with x and y values in CSS pixel units.
* @returns The input coordinate divided by the workspace scale.
*/
protected pixelsToWorkspaceUnits_(pixelCoord: Coordinate): Coordinate {
const result = new Coordinate(
pixelCoord.x / this.workspace_.scale,
pixelCoord.y / this.workspace_.scale,
);
if (this.workspace_.isMutator) {
// If we're in a mutator, its scale is always 1, purely because of some
// oddities in our rendering optimizations. The actual scale is the same
// as the scale on the parent workspace. Fix that for dragging.
const mainScale = this.workspace_.options.parentWorkspace!.scale;
result.scale(1 / mainScale);
}
return result;
}
/**
* Move all of the icons connected to this drag.
*
* @deprecated To be removed in v11. This is now handled by the block's
* `moveDuringDrag` method.
*/
protected dragIcons_() {
deprecation.warn('Blockly.BlockDragger.prototype.dragIcons_', 'v10', 'v11');
}
/**
* 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.
*/
getInsertionMarkers(): BlockSvg[] {
return this.workspace_
.getAllBlocks()
.filter((block) => block.isInsertionMarker());
}
}
/** Data about the position of a given icon. */
export interface IconPositionData {
location: Coordinate;
icon: Icon;
}
/**
* Make a list of all of the icons (comment, warning, and mutator) that are
* on this block and its descendants. Moving an icon moves the bubble that
* extends from it if that bubble is open.
*
* @param block The root block that is being dragged.
* @param blockOrigin The top left of the given block in workspace coordinates.
* @returns The list of all icons and their locations.
*/
function initIconData(
block: BlockSvg,
blockOrigin: Coordinate,
): IconPositionData[] {
// Build a list of icons that need to be moved and where they started.
const dragIconData = [];
for (const icon of block.getIcons()) {
// Only bother to track icons whose bubble is visible.
if (hasBubble(icon) && !icon.bubbleIsVisible()) continue;
dragIconData.push({location: blockOrigin, icon: icon});
icon.onLocationChange(blockOrigin);
}
for (const child of block.getChildren(false)) {
dragIconData.push(
...initIconData(child, Coordinate.sum(blockOrigin, child.relativeCoords)),
);
}
// AnyDuringMigration because: Type '{ location: Coordinate | null; icon:
// Icon; }[]' is not assignable to type 'IconPositionData[]'.
return dragIconData as AnyDuringMigration;
}
registry.register(registry.Type.BLOCK_DRAGGER, registry.DEFAULT, BlockDragger);

View File

@@ -17,7 +17,6 @@ import './events/events_selected.js';
import {Block} from './block.js';
import * as blockAnimations from './block_animations.js';
import * as browserEvents from './browser_events.js';
import {CommentIcon} from './icons/comment_icon.js';
import * as common from './common.js';
import {config} from './config.js';
import type {Connection} from './connection.js';
@@ -30,6 +29,7 @@ import {
LegacyContextMenuOption,
} from './contextmenu_registry.js';
import type {BlockMove} from './events/events_block_move.js';
import * as deprecation from './utils/deprecation.js';
import * as eventUtils from './events/utils.js';
import type {Field} from './field.js';
import {FieldLabel} from './field_label.js';
@@ -37,7 +37,7 @@ import type {Input} from './inputs/input.js';
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import type {IDraggable} from './interfaces/i_draggable.js';
import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js';
import {IIcon} from './interfaces/i_icon.js';
import * as internalConstants from './internal_constants.js';
import {ASTNode} from './keyboard_nav/ast_node.js';
@@ -59,9 +59,11 @@ import {WarningIcon} from './icons/warning_icon.js';
import type {Workspace} from './workspace.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as renderManagement from './render_management.js';
import * as deprecation from './utils/deprecation.js';
import {IconType} from './icons/icon_types.js';
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
import {BlockDragStrategy} from './dragging/block_drag_strategy.js';
import {IDeletable} from './blockly.js';
import {FlyoutItemInfo} from './utils/toolbox.js';
/**
* Class for a block's SVG representation.
@@ -73,7 +75,8 @@ export class BlockSvg
IASTNodeLocationSvg,
IBoundedElement,
ICopyable<BlockCopyData>,
IDraggable
IDraggable,
IDeletable
{
/**
* Constant for identifying rows that are to be rendered inline.
@@ -115,18 +118,14 @@ export class BlockSvg
/** Block's mutator icon (if any). */
mutator: MutatorIcon | null = null;
/**
* Block's warning icon (if any).
*
* @deprecated Use `setWarningText` to modify warnings on this block.
*/
warning: WarningIcon | null = null;
private svgGroup_: SVGGElement;
style: BlockStyle;
/** @internal */
pathObject: IPathObject;
override rendered = false;
/** Is this block a BlockSVG? */
override readonly rendered = true;
private visuallyDisabled = false;
/**
@@ -148,12 +147,6 @@ export class BlockSvg
private translation = '';
/**
* The ID of the setTimeout callback for bumping neighbours, or 0 if no bump
* is currently scheduled.
*/
private bumpNeighboursPid = 0;
/** Whether this block is currently being dragged. */
private dragging = false;
@@ -166,6 +159,8 @@ export class BlockSvg
*/
relativeCoords = new Coordinate(0, 0);
private dragStrategy: IDragStrategy = new BlockDragStrategy(this);
/**
* @param workspace The block's workspace.
* @param prototypeName Name of the language object containing type-specific
@@ -175,6 +170,9 @@ export class BlockSvg
*/
constructor(workspace: WorkspaceSvg, prototypeName: string, opt_id?: string) {
super(workspace, prototypeName, opt_id);
if (!workspace.rendered) {
throw TypeError('Cannot create a rendered block in a headless workspace');
}
this.workspace = workspace;
this.svgGroup_ = dom.createSvgElement(Svg.G, {});
@@ -201,10 +199,8 @@ export class BlockSvg
* May be called more than once.
*/
initSvg() {
if (!this.workspace.rendered) {
throw TypeError('Workspace is headless.');
}
for (let i = 0, input; (input = this.inputList[i]); i++) {
if (this.initialized) return;
for (const input of this.inputList) {
input.init();
}
for (const icon of this.getIcons()) {
@@ -214,7 +210,7 @@ export class BlockSvg
this.applyColour();
this.pathObject.updateMovable(this.isMovable());
const svg = this.getSvgRoot();
if (!this.workspace.options.readOnly && !this.eventsInit_ && svg) {
if (!this.workspace.options.readOnly && svg) {
browserEvents.conditionalBind(
svg,
'pointerdown',
@@ -222,11 +218,11 @@ export class BlockSvg
this.onMouseDown_,
);
}
this.eventsInit_ = true;
if (!svg.parentNode) {
this.workspace.getCanvas().appendChild(svg);
}
this.initialized = true;
}
/**
@@ -247,56 +243,21 @@ export class BlockSvg
return this.style.colourTertiary;
}
/**
* Selects this block. Highlights the block visually and fires a select event
* if the block is not already selected.
*/
/** Selects this block. Highlights the block visually. */
select() {
if (this.isShadow() && this.getParent()) {
// Shadow blocks should not be selected.
this.getParent()!.select();
if (this.isShadow()) {
this.getParent()?.select();
return;
}
if (common.getSelected() === this) {
return;
}
let oldId = null;
if (common.getSelected()) {
oldId = common.getSelected()!.id;
// Unselect any previously selected block.
eventUtils.disable();
try {
common.getSelected()!.unselect();
} finally {
eventUtils.enable();
}
}
const event = new (eventUtils.get(eventUtils.SELECTED))(
oldId,
this.id,
this.workspace.id,
);
eventUtils.fire(event);
common.setSelected(this);
this.addSelect();
}
/**
* Unselects this block. Unhighlights the block and fires a select (false)
* event if the block is currently selected.
*/
/** Unselects this block. Unhighlights the blockv visually. */
unselect() {
if (common.getSelected() !== this) {
if (this.isShadow()) {
this.getParent()?.unselect();
return;
}
const event = new (eventUtils.get(eventUtils.SELECTED))(
this.id,
null,
this.workspace.id,
);
event.workspaceId = this.workspace.id;
eventUtils.fire(event);
common.setSelected(null);
this.removeSelect();
}
@@ -455,34 +416,15 @@ export class BlockSvg
/** Snap this block to the nearest grid point. */
snapToGrid() {
if (this.isDeadOrDying()) {
return; // Deleted block.
}
if (this.workspace.isDragging()) {
return; // Don't bump blocks during a drag.
}
if (this.getParent()) {
return; // Only snap top-level blocks.
}
if (this.isInFlyout) {
return; // Don't move blocks around in a flyout.
}
if (this.isDeadOrDying()) return;
if (this.getParent()) return;
if (this.isInFlyout) return;
const grid = this.workspace.getGrid();
if (!grid || !grid.shouldSnap()) {
return; // Config says no snapping.
}
const spacing = grid.getSpacing();
const half = spacing / 2;
const xy = this.getRelativeToSurfaceXY();
const dx = Math.round(
Math.round((xy.x - half) / spacing) * spacing + half - xy.x,
);
const dy = Math.round(
Math.round((xy.y - half) / spacing) * spacing + half - xy.y,
);
if (dx || dy) {
this.moveBy(dx, dy, ['snap']);
if (!grid?.shouldSnap()) return;
const currentXY = this.getRelativeToSurfaceXY();
const alignedXY = grid.alignXY(currentXY);
if (alignedXY !== currentXY) {
this.moveTo(alignedXY, ['snap']);
}
}
@@ -653,7 +595,7 @@ export class BlockSvg
* @param e Mouse event.
* @internal
*/
showContextMenu(e: Event) {
showContextMenu(e: PointerEvent) {
const menuOptions = this.generateContextMenu();
if (menuOptions && menuOptions.length) {
@@ -670,12 +612,6 @@ export class BlockSvg
* @internal
*/
updateComponentLocations(blockOrigin: Coordinate) {
if (!this.rendered) {
// Rendering is required to lay out the blocks.
// This is probably an invisible block attached to a collapsed block.
return;
}
if (!this.dragging) this.updateConnectionLocations(blockOrigin);
this.updateIconLocations(blockOrigin);
this.updateFieldLocations(blockOrigin);
@@ -804,12 +740,12 @@ export class BlockSvg
* @param animate If true, show a disposal animation and sound.
*/
override dispose(healStack?: boolean, animate?: boolean) {
if (this.isDeadOrDying()) return;
this.disposing = true;
Tooltip.dispose();
ContextMenu.hide();
if (animate && this.rendered) {
if (animate) {
this.unplug(healStack);
blockAnimations.disposeUiEffect(this);
}
@@ -823,11 +759,9 @@ export class BlockSvg
* E.g. does trigger UI effects, remove nodes, etc.
*/
override disposeInternal() {
if (this.isDeadOrDying()) return;
this.disposing = true;
super.disposeInternal();
this.rendered = false;
if (common.getSelected() === this) {
this.unselect();
this.workspace.cancelCurrentGesture();
@@ -922,18 +856,6 @@ export class BlockSvg
}
}
/**
* Get the comment icon attached to this block, or null if the block has no
* comment.
*
* @returns The comment icon attached to this block, or null.
* @deprecated Use getIcon. To be remove in v11.
*/
getCommentIcon(): CommentIcon | null {
deprecation.warn('getCommentIcon', 'v10', 'v11', 'getIcon');
return (this.getIcon(CommentIcon.TYPE) ?? null) as CommentIcon | null;
}
/**
* Set this block's warning text.
*
@@ -1021,17 +943,12 @@ export class BlockSvg
override addIcon<T extends IIcon>(icon: T): T {
super.addIcon(icon);
if (icon instanceof WarningIcon) this.warning = icon;
if (icon instanceof MutatorIcon) this.mutator = icon;
if (this.rendered) {
icon.initView(this.createIconPointerDownListener(icon));
icon.applyColour();
icon.updateEditable();
this.queueRender();
renderManagement.triggerQueuedRenders();
this.bumpNeighbours();
}
icon.initView(this.createIconPointerDownListener(icon));
icon.applyColour();
icon.updateEditable();
this.queueRender();
return icon;
}
@@ -1053,28 +970,56 @@ export class BlockSvg
override removeIcon(type: IconType<IIcon>): boolean {
const removed = super.removeIcon(type);
if (type.equals(WarningIcon.TYPE)) this.warning = null;
if (type.equals(MutatorIcon.TYPE)) this.mutator = null;
if (this.rendered) {
this.queueRender();
renderManagement.triggerQueuedRenders();
this.bumpNeighbours();
}
this.queueRender();
return removed;
}
/**
* Set whether the block is enabled or not.
* @deprecated v11 - Set whether the block is manually enabled or disabled.
* The user can toggle whether a block is disabled from a context menu
* option. A block may still be disabled for other reasons even if the user
* attempts to manually enable it, such as when the block is in an invalid
* location. This method is deprecated and setDisabledReason should be used
* instead.
*
* @param enabled True if enabled.
*/
override setEnabled(enabled: boolean) {
if (this.isEnabled() !== enabled) {
super.setEnabled(enabled);
if (this.rendered && !this.getInheritedDisabled()) {
this.updateDisabled();
}
deprecation.warn(
'setEnabled',
'v11',
'v12',
'the setDisabledReason method of BlockSvg',
);
const wasEnabled = this.isEnabled();
super.setEnabled(enabled);
if (this.isEnabled() !== wasEnabled && !this.getInheritedDisabled()) {
this.updateDisabled();
}
}
/**
* Add or remove a reason why the block might be disabled. If a block has
* any reasons to be disabled, then the block itself will be considered
* disabled. A block could be disabled for multiple independent reasons
* simultaneously, such as when the user manually disables it, or the block
* is invalid.
*
* @param disabled If true, then the block should be considered disabled for
* at least the provided reason, otherwise the block is no longer disabled
* for that reason.
* @param reason A language-neutral identifier for a reason why the block
* could be disabled. Call this method again with the same identifier to
* update whether the block is currently disabled for this reason.
*/
override setDisabledReason(disabled: boolean, reason: string): void {
const wasEnabled = this.isEnabled();
super.setDisabledReason(disabled, reason);
if (this.isEnabled() !== wasEnabled && !this.getInheritedDisabled()) {
this.updateDisabled();
}
}
@@ -1085,9 +1030,6 @@ export class BlockSvg
* @param highlighted True if highlighted.
*/
setHighlighted(highlighted: boolean) {
if (!this.rendered) {
return;
}
this.pathObject.updateHighlighted(highlighted);
}
@@ -1219,11 +1161,7 @@ export class BlockSvg
opt_check?: string | string[] | null,
) {
super.setPreviousStatement(newBoolean, opt_check);
if (this.rendered) {
this.queueRender();
this.bumpNeighbours();
}
this.queueRender();
}
/**
@@ -1238,11 +1176,7 @@ export class BlockSvg
opt_check?: string | string[] | null,
) {
super.setNextStatement(newBoolean, opt_check);
if (this.rendered) {
this.queueRender();
this.bumpNeighbours();
}
this.queueRender();
}
/**
@@ -1257,11 +1191,7 @@ export class BlockSvg
opt_check?: string | string[] | null,
) {
super.setOutput(newBoolean, opt_check);
if (this.rendered) {
this.queueRender();
this.bumpNeighbours();
}
this.queueRender();
}
/**
@@ -1271,11 +1201,7 @@ export class BlockSvg
*/
override setInputsInline(newBoolean: boolean) {
super.setInputsInline(newBoolean);
if (this.rendered) {
this.queueRender();
this.bumpNeighbours();
}
this.queueRender();
}
/**
@@ -1289,13 +1215,7 @@ export class BlockSvg
*/
override removeInput(name: string, opt_quiet?: boolean): boolean {
const removed = super.removeInput(name, opt_quiet);
if (this.rendered) {
this.queueRender();
// Removing an input will cause the block to change shape.
this.bumpNeighbours();
}
this.queueRender();
return removed;
}
@@ -1307,23 +1227,13 @@ export class BlockSvg
*/
override moveNumberedInputBefore(inputIndex: number, refIndex: number) {
super.moveNumberedInputBefore(inputIndex, refIndex);
if (this.rendered) {
this.queueRender();
// Moving an input will cause the block to change shape.
this.bumpNeighbours();
}
this.queueRender();
}
/** @override */
override appendInput(input: Input): Input {
super.appendInput(input);
if (this.rendered) {
this.queueRender();
// Adding an input will cause the block to change shape.
this.bumpNeighbours();
}
this.queueRender();
return input;
}
@@ -1377,28 +1287,25 @@ export class BlockSvg
* Returns connections originating from this block.
*
* @param all If true, return all connections even hidden ones.
* Otherwise, for a non-rendered block return an empty list, and for a
* collapsed block don't return inputs connections.
* Otherwise, for a collapsed block don't return inputs connections.
* @returns Array of connections.
* @internal
*/
override getConnections_(all: boolean): RenderedConnection[] {
const myConnections = [];
if (all || this.rendered) {
if (this.outputConnection) {
myConnections.push(this.outputConnection);
}
if (this.previousConnection) {
myConnections.push(this.previousConnection);
}
if (this.nextConnection) {
myConnections.push(this.nextConnection);
}
if (all || !this.collapsed_) {
for (let i = 0, input; (input = this.inputList[i]); i++) {
if (input.connection) {
myConnections.push(input.connection as RenderedConnection);
}
if (this.outputConnection) {
myConnections.push(this.outputConnection);
}
if (this.previousConnection) {
myConnections.push(this.previousConnection);
}
if (this.nextConnection) {
myConnections.push(this.nextConnection);
}
if (all || !this.collapsed_) {
for (let i = 0, input; (input = this.inputList[i]); i++) {
if (input.connection) {
myConnections.push(input.connection as RenderedConnection);
}
}
}
@@ -1474,22 +1381,6 @@ export class BlockSvg
* up on screen, because that creates confusion for end-users.
*/
override bumpNeighbours() {
if (this.bumpNeighboursPid) return;
const group = eventUtils.getGroup();
this.bumpNeighboursPid = setTimeout(() => {
const oldGroup = eventUtils.getGroup();
eventUtils.setGroup(group);
this.getRootBlock().bumpNeighboursInternal();
eventUtils.setGroup(oldGroup);
this.bumpNeighboursPid = 0;
}, config.bumpDelay);
}
/**
* Bumps unconnected blocks out of alignment.
*/
private bumpNeighboursInternal() {
const root = this.getRootBlock();
if (
this.isDeadOrDying() ||
@@ -1506,16 +1397,13 @@ export class BlockSvg
for (const conn of this.getConnections_(false)) {
if (conn.isSuperior()) {
// Recurse down the block stack.
conn.targetBlock()?.bumpNeighboursInternal();
conn.targetBlock()?.bumpNeighbours();
}
for (const neighbour of conn.neighbours(config.snapRadius)) {
// Don't bump away from things that are in our stack.
if (neighbourIsInStack(neighbour)) continue;
// If both connections are connected, that's fine.
if (conn.isConnected() && neighbour.isConnected()) continue;
// Always bump the inferior connection.
if (conn.isSuperior()) {
neighbour.bumpAwayFrom(conn);
} else {
@@ -1526,21 +1414,11 @@ export class BlockSvg
}
/**
* Schedule snapping to grid and bumping neighbours to occur after a brief
* delay.
*
* @internal
* Snap to grid, and then bump neighbouring blocks away at the end of the next
* render.
*/
scheduleSnapAndBump() {
// Ensure that any snap and bump are part of this move's event group.
const group = eventUtils.getGroup();
setTimeout(() => {
eventUtils.setGroup(group);
this.snapToGrid();
eventUtils.setGroup(false);
}, config.bumpDelay / 2);
this.snapToGrid();
this.bumpNeighbours();
}
@@ -1617,7 +1495,6 @@ export class BlockSvg
* @internal
*/
renderEfficiently() {
this.rendered = true;
dom.startTextWidthCache();
if (this.isCollapsed()) {
@@ -1741,4 +1618,60 @@ export class BlockSvg
add,
);
}
/** Sets the drag strategy for this block. */
setDragStrategy(dragStrategy: IDragStrategy) {
this.dragStrategy = dragStrategy;
}
/** Returns whether this block is movable or not. */
override isMovable(): boolean {
return this.dragStrategy.isMovable();
}
/** Starts a drag on the block. */
startDrag(e?: PointerEvent): void {
this.dragStrategy.startDrag(e);
}
/** Drags the block to the given location. */
drag(newLoc: Coordinate, e?: PointerEvent): void {
this.dragStrategy.drag(newLoc, e);
}
/** Ends the drag on the block. */
endDrag(e?: PointerEvent): void {
this.dragStrategy.endDrag(e);
}
/** Moves the block back to where it was at the start of a drag. */
revertDrag(): void {
this.dragStrategy.revertDrag();
}
/**
* Returns a representation of this block that can be displayed in a flyout.
*/
toFlyoutInfo(): FlyoutItemInfo[] {
const json: FlyoutItemInfo = {
kind: 'BLOCK',
...blocks.save(this),
};
const toRemove = new Set(['id', 'height', 'width', 'pinned', 'enabled']);
// Traverse the JSON recursively.
const traverseJson = function (json: {[key: string]: unknown}) {
for (const key in json) {
if (toRemove.has(key)) {
delete json[key];
} else if (typeof json[key] === 'object') {
traverseJson(json[key] as {[key: string]: unknown});
}
}
};
traverseJson(json as unknown as {[key: string]: unknown});
return [json];
}
}

View File

@@ -17,14 +17,11 @@ import './events/events_var_create.js';
import {Block} from './block.js';
import * as blockAnimations from './block_animations.js';
import {BlockDragger} from './block_dragger.js';
import {BlockSvg} from './block_svg.js';
import {BlocklyOptions} from './blockly_options.js';
import {Blocks} from './blocks.js';
import * as browserEvents from './browser_events.js';
import {Bubble} from './bubbles/bubble.js';
import * as bubbles from './bubbles.js';
import {BubbleDragger} from './bubble_dragger.js';
import * as bumpObjects from './bump_objects.js';
import * as clipboard from './clipboard.js';
import * as common from './common.js';
@@ -37,9 +34,11 @@ import {ConnectionType} from './connection_type.js';
import * as ContextMenu from './contextmenu.js';
import * as ContextMenuItems from './contextmenu_items.js';
import {ContextMenuRegistry} from './contextmenu_registry.js';
import * as comments from './comments.js';
import * as Css from './css.js';
import {DeleteArea} from './delete_area.js';
import * as dialog from './dialog.js';
import * as dragging from './dragging.js';
import {DragTarget} from './drag_target.js';
import * as dropDownDiv from './dropdowndiv.js';
import * as Events from './events/events.js';
@@ -50,24 +49,12 @@ import {
FieldValidator,
UnattachedFieldError,
} from './field.js';
import {
FieldAngle,
FieldAngleConfig,
FieldAngleFromJsonConfig,
FieldAngleValidator,
} from './field_angle.js';
import {
FieldCheckbox,
FieldCheckboxConfig,
FieldCheckboxFromJsonConfig,
FieldCheckboxValidator,
} from './field_checkbox.js';
import {
FieldColour,
FieldColourConfig,
FieldColourFromJsonConfig,
FieldColourValidator,
} from './field_colour.js';
import {
FieldDropdown,
FieldDropdownConfig,
@@ -88,12 +75,6 @@ import {
FieldLabelFromJsonConfig,
} from './field_label.js';
import {FieldLabelSerializable} from './field_label_serializable.js';
import {
FieldMultilineInput,
FieldMultilineInputConfig,
FieldMultilineInputFromJsonConfig,
FieldMultilineInputValidator,
} from './field_multilineinput.js';
import {
FieldNumber,
FieldNumberConfig,
@@ -123,9 +104,7 @@ import {Gesture} from './gesture.js';
import {Grid} from './grid.js';
import * as icons from './icons.js';
import {inject} from './inject.js';
import {Align} from './inputs/align.js';
import {Input} from './inputs/input.js';
import {inputTypes} from './inputs/input_types.js';
import * as inputs from './inputs.js';
import {InsertionMarkerManager} from './insertion_marker_manager.js';
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
@@ -133,7 +112,6 @@ import {IASTNodeLocation} from './interfaces/i_ast_node_location.js';
import {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import {IBlockDragger} from './interfaces/i_block_dragger.js';
import {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IBubble} from './interfaces/i_bubble.js';
import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js';
@@ -141,11 +119,16 @@ import {IComponent} from './interfaces/i_component.js';
import {IConnectionChecker} from './interfaces/i_connection_checker.js';
import {IConnectionPreviewer} from './interfaces/i_connection_previewer.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
import {ICopyable, isCopyable} from './interfaces/i_copyable.js';
import {IDeletable} from './interfaces/i_deletable.js';
import {ICopyable, isCopyable, ICopyData} from './interfaces/i_copyable.js';
import {IDeletable, isDeletable} from './interfaces/i_deletable.js';
import {IDeleteArea} from './interfaces/i_delete_area.js';
import {IDragTarget} from './interfaces/i_drag_target.js';
import {IDraggable} from './interfaces/i_draggable.js';
import {IDragger} from './interfaces/i_dragger.js';
import {
IDraggable,
isDraggable,
IDragStrategy,
} from './interfaces/i_draggable.js';
import {IFlyout} from './interfaces/i_flyout.js';
import {IHasBubble, hasBubble} from './interfaces/i_has_bubble.js';
import {IIcon, isIcon} from './interfaces/i_icon.js';
@@ -156,7 +139,11 @@ import {IObservable, isObservable} from './interfaces/i_observable.js';
import {IPaster, isPaster} from './interfaces/i_paster.js';
import {IPositionable} from './interfaces/i_positionable.js';
import {IRegistrable} from './interfaces/i_registrable.js';
import {ISelectable} from './interfaces/i_selectable.js';
import {
IRenderedElement,
isRenderedElement,
} from './interfaces/i_rendered_element.js';
import {ISelectable, isSelectable} from './interfaces/i_selectable.js';
import {ISelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js';
import {ISerializable, isSerializable} from './interfaces/i_serializable.js';
import {IStyleable} from './interfaces/i_styleable.js';
@@ -173,6 +160,7 @@ import {Cursor} from './keyboard_nav/cursor.js';
import {Marker} from './keyboard_nav/marker.js';
import {TabNavigateCursor} from './keyboard_nav/tab_navigate_cursor.js';
import {MarkerManager} from './marker_manager.js';
import type {LayerManager} from './layer_manager.js';
import {Menu} from './menu.js';
import {MenuItem} from './menuitem.js';
import {MetricsManager} from './metrics_manager.js';
@@ -188,7 +176,6 @@ import * as renderManagement from './render_management.js';
import * as blockRendering from './renderers/common/block_rendering.js';
import * as constants from './constants.js';
import * as geras from './renderers/geras/geras.js';
import * as minimalist from './renderers/minimalist/minimalist.js';
import * as thrasos from './renderers/thrasos/thrasos.js';
import * as zelos from './renderers/zelos/zelos.js';
import {Scrollbar} from './scrollbar.js';
@@ -216,8 +203,6 @@ import * as VariablesDynamic from './variables_dynamic.js';
import * as WidgetDiv from './widgetdiv.js';
import {Workspace} from './workspace.js';
import {WorkspaceAudio} from './workspace_audio.js';
import {WorkspaceComment} from './workspace_comment.js';
import {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import {WorkspaceDragger} from './workspace_dragger.js';
import {WorkspaceSvg} from './workspace_svg.js';
import * as Xml from './xml.js';
@@ -243,27 +228,6 @@ export const VERSION = 'uncompiled';
* namespace to put new functions on.
*/
/*
* Aliases for input alignments used in block defintions.
*/
/**
* @see Blockly.Input.Align.LEFT
* @deprecated Use `Blockly.inputs.Align.LEFT`. To be removed in v11.
*/
export const ALIGN_LEFT = Align.LEFT;
/**
* @see Blockly.Input.Align.CENTRE
* @deprecated Use `Blockly.inputs.Align.CENTER`. To be removed in v11.
*/
export const ALIGN_CENTRE = Align.CENTRE;
/**
* @see Blockly.Input.Align.RIGHT
* @deprecated Use `Blockly.inputs.Align.RIGHT`. To be removed in v11.
*/
export const ALIGN_RIGHT = Align.RIGHT;
/*
* Aliases for constants used for connection and input types.
*/
@@ -288,12 +252,6 @@ export const NEXT_STATEMENT = ConnectionType.NEXT_STATEMENT;
*/
export const PREVIOUS_STATEMENT = ConnectionType.PREVIOUS_STATEMENT;
/**
* @see inputTypes.DUMMY_INPUT
* @deprecated Use `Blockly.inputs.inputTypes.DUMMY`. To be removed in v11.
*/
export const DUMMY_INPUT = inputTypes.DUMMY;
/** Aliases for toolbox positions. */
/**
@@ -378,7 +336,6 @@ export const setParentContainer = common.setParentContainer;
// Aliases to allow external code to access these values for legacy reasons.
export const COLLAPSE_CHARS = internalConstants.COLLAPSE_CHARS;
export const DRAG_STACK = internalConstants.DRAG_STACK;
export const OPPOSITE_TYPE = internalConstants.OPPOSITE_TYPE;
export const RENAME_VARIABLE_ID = internalConstants.RENAME_VARIABLE_ID;
export const DELETE_VARIABLE_ID = internalConstants.DELETE_VARIABLE_ID;
@@ -424,25 +381,20 @@ WorkspaceSvg.prototype.newBlock = function (
return new BlockSvg(this, prototypeName, opt_id);
};
WorkspaceSvg.newTrashcan = function (workspace: WorkspaceSvg): Trashcan {
return new Trashcan(workspace);
Workspace.prototype.newComment = function (
id?: string,
): comments.WorkspaceComment {
return new comments.WorkspaceComment(this, id);
};
WorkspaceCommentSvg.prototype.showContextMenu = function (
this: WorkspaceCommentSvg,
e: Event,
) {
if (this.workspace.options.readOnly) {
return;
}
const menuOptions = [];
WorkspaceSvg.prototype.newComment = function (
id?: string,
): comments.RenderedWorkspaceComment {
return new comments.RenderedWorkspaceComment(this, id);
};
if (this.isDeletable() && this.isMovable()) {
menuOptions.push(ContextMenu.commentDuplicateOption(this));
menuOptions.push(ContextMenu.commentDeleteOption(this));
}
ContextMenu.show(e, menuOptions, this.RTL);
WorkspaceSvg.newTrashcan = function (workspace: WorkspaceSvg): Trashcan {
return new Trashcan(workspace);
};
MiniWorkspaceBubble.prototype.newWorkspaceSvg = function (
@@ -490,7 +442,6 @@ export {constants};
export {dialog};
export {fieldRegistry};
export {geras};
export {minimalist};
export {registry};
export {thrasos};
export {uiPosition};
@@ -500,13 +451,9 @@ export {ASTNode};
export {BasicCursor};
export {Block};
export {BlocklyOptions};
export {BlockDragger};
export {BlockSvg};
export {Blocks};
export {bubbles};
/** @deprecated Use Blockly.bubbles.Bubble instead. To be removed in v11. */
export {Bubble};
export {BubbleDragger};
export {CollapsibleToolboxCategory};
export {ComponentManager};
export {Connection};
@@ -514,29 +461,19 @@ export {ConnectionType};
export {ConnectionChecker};
export {ConnectionDB};
export {ContextMenuRegistry};
export {comments};
export {Cursor};
export {DeleteArea};
export {dragging};
export {DragTarget};
export const DropDownDiv = dropDownDiv;
export {Field, FieldConfig, FieldValidator, UnattachedFieldError};
export {
FieldAngle,
FieldAngleConfig,
FieldAngleFromJsonConfig,
FieldAngleValidator,
};
export {
FieldCheckbox,
FieldCheckboxConfig,
FieldCheckboxFromJsonConfig,
FieldCheckboxValidator,
};
export {
FieldColour,
FieldColourConfig,
FieldColourFromJsonConfig,
FieldColourValidator,
};
export {
FieldDropdown,
FieldDropdownConfig,
@@ -549,12 +486,6 @@ export {
export {FieldImage, FieldImageConfig, FieldImageFromJsonConfig};
export {FieldLabel, FieldLabelConfig, FieldLabelFromJsonConfig};
export {FieldLabelSerializable};
export {
FieldMultilineInput,
FieldMultilineInputConfig,
FieldMultilineInputFromJsonConfig,
FieldMultilineInputValidator,
};
export {
FieldNumber,
FieldNumberConfig,
@@ -585,7 +516,6 @@ export {IASTNodeLocation};
export {IASTNodeLocationSvg};
export {IASTNodeLocationWithBlock};
export {IAutoHideable};
export {IBlockDragger};
export {IBoundedElement};
export {IBubble};
export {ICollapsibleToolboxItem};
@@ -594,11 +524,12 @@ export {IConnectionChecker};
export {IConnectionPreviewer};
export {IContextMenu};
export {icons};
export {ICopyable, isCopyable};
export {IDeletable};
export {ICopyable, isCopyable, ICopyData};
export {IDeletable, isDeletable};
export {IDeleteArea};
export {IDragTarget};
export {IDraggable};
export {IDragger};
export {IDraggable, isDraggable, IDragStrategy};
export {IFlyout};
export {IHasBubble, hasBubble};
export {IIcon, isIcon};
@@ -613,7 +544,8 @@ export {IObservable, isObservable};
export {IPaster, isPaster};
export {IPositionable};
export {IRegistrable};
export {ISelectable};
export {IRenderedElement, isRenderedElement};
export {ISelectable, isSelectable};
export {ISelectableToolboxItem};
export {ISerializable, isSerializable};
export {IStyleable};
@@ -622,6 +554,7 @@ export {IToolboxItem};
export {IVariableBackedParameterModel, isVariableBackedParameterModel};
export {Marker};
export {MarkerManager};
export {LayerManager};
export {Menu};
export {MenuItem};
export {MetricsManager};
@@ -646,15 +579,9 @@ export {VariableModel};
export {VerticalFlyout};
export {Workspace};
export {WorkspaceAudio};
export {WorkspaceComment};
export {WorkspaceCommentSvg};
export {WorkspaceDragger};
export {WorkspaceSvg};
export {ZoomControls};
export {config};
/** @deprecated Use Blockly.ConnectionType instead. */
export const connectionTypes = ConnectionType;
export {inject};
/** @deprecated Use Blockly.inputs.inputTypes instead. To be removed in v11. */
export {inputTypes};
export {serialization};

View File

@@ -1,217 +0,0 @@
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Methods for dragging a bubble visually.
*
* @class
*/
// Former goog.module ID: Blockly.BubbleDragger
import {ComponentManager} from './component_manager.js';
import type {CommentMove} from './events/events_comment_move.js';
import * as eventUtils from './events/utils.js';
import type {IBubble} from './interfaces/i_bubble.js';
import type {IDeleteArea} from './interfaces/i_delete_area.js';
import type {IDragTarget} from './interfaces/i_drag_target.js';
import {Coordinate} from './utils/coordinate.js';
import {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as layers from './layers.js';
/**
* Class for a bubble dragger. It moves things on the bubble canvas around the
* workspace when they are being dragged by a mouse or touch. These can be
* block comments, mutators, warnings, or workspace comments.
*/
export class BubbleDragger {
/** Which drag target the mouse pointer is over, if any. */
private dragTarget_: IDragTarget | null = null;
/** Whether the bubble would be deleted if dropped immediately. */
private wouldDeleteBubble_ = false;
private readonly startXY_: Coordinate;
/**
* @param bubble The item on the bubble canvas to drag.
* @param workspace The workspace to drag on.
*/
constructor(
private bubble: IBubble,
private workspace: WorkspaceSvg,
) {
/**
* The location of the top left corner of the dragging bubble's body at the
* beginning of the drag, in workspace coordinates.
*/
this.startXY_ = this.bubble.getRelativeToSurfaceXY();
}
/**
* Start dragging a bubble.
*
* @internal
*/
startBubbleDrag() {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.workspace.setResizesEnabled(false);
if ((this.bubble as AnyDuringMigration).setAutoLayout) {
(this.bubble as AnyDuringMigration).setAutoLayout(false);
}
this.workspace.getLayerManager()?.moveToDragLayer(this.bubble);
this.bubble.setDragging && this.bubble.setDragging(true);
}
/**
* Execute a step of bubble dragging, based on the given event. Update the
* display accordingly.
*
* @param e The most recent move event.
* @param currentDragDeltaXY How far the pointer has moved from the position
* at the start of the drag, in pixel units.
* @internal
*/
dragBubble(e: PointerEvent, currentDragDeltaXY: Coordinate) {
const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
const newLoc = Coordinate.sum(this.startXY_, delta);
this.bubble.moveDuringDrag(newLoc);
const oldDragTarget = this.dragTarget_;
this.dragTarget_ = this.workspace.getDragTarget(e);
const oldWouldDeleteBubble = this.wouldDeleteBubble_;
this.wouldDeleteBubble_ = this.shouldDelete_(this.dragTarget_);
if (oldWouldDeleteBubble !== this.wouldDeleteBubble_) {
// Prevent unnecessary add/remove class calls.
this.updateCursorDuringBubbleDrag_();
}
// Call drag enter/exit/over after wouldDeleteBlock is called in
// shouldDelete_
if (this.dragTarget_ !== oldDragTarget) {
oldDragTarget && oldDragTarget.onDragExit(this.bubble);
this.dragTarget_ && this.dragTarget_.onDragEnter(this.bubble);
}
this.dragTarget_ && this.dragTarget_.onDragOver(this.bubble);
}
/**
* Whether ending the drag would delete the bubble.
*
* @param dragTarget The drag target that the bubblee is currently over.
* @returns Whether dropping the bubble immediately would delete the block.
*/
private shouldDelete_(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.bubble, false);
}
}
return false;
}
/**
* Update the cursor (and possibly the trash can lid) to reflect whether the
* dragging bubble would be deleted if released immediately.
*/
private updateCursorDuringBubbleDrag_() {
this.bubble.setDeleteStyle(this.wouldDeleteBubble_);
}
/**
* Finish a bubble drag and put the bubble back on the workspace.
*
* @param e The pointerup event.
* @param currentDragDeltaXY How far the pointer has moved from the position
* at the start of the drag, in pixel units.
* @internal
*/
endBubbleDrag(e: PointerEvent, currentDragDeltaXY: Coordinate) {
// Make sure internal state is fresh.
this.dragBubble(e, currentDragDeltaXY);
const preventMove =
this.dragTarget_ && this.dragTarget_.shouldPreventMove(this.bubble);
let newLoc;
if (preventMove) {
newLoc = this.startXY_;
} else {
const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
newLoc = Coordinate.sum(this.startXY_, delta);
}
// Move the bubble to its final location.
this.bubble.moveTo(newLoc.x, newLoc.y);
if (this.dragTarget_) {
this.dragTarget_.onDrop(this.bubble);
}
if (this.wouldDeleteBubble_) {
// Fire a move event, so we know where to go back to for an undo.
this.fireMoveEvent_();
this.bubble.dispose();
} else {
// Put everything back onto the bubble canvas.
if (this.bubble.setDragging) {
this.bubble.setDragging(false);
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.bubble, layers.BUBBLE);
}
this.fireMoveEvent_();
}
this.workspace.setResizesEnabled(true);
eventUtils.setGroup(false);
}
/** Fire a move event at the end of a bubble drag. */
private fireMoveEvent_() {
if (this.bubble instanceof WorkspaceCommentSvg) {
const event = new (eventUtils.get(eventUtils.COMMENT_MOVE))(
this.bubble,
) as CommentMove;
event.setOldCoordinate(this.startXY_);
event.recordNew();
eventUtils.fire(event);
}
// TODO (fenichel): move events for comments.
return;
}
/**
* Convert a coordinate object from pixels to workspace units, including a
* correction for mutator workspaces.
* This function does not consider differing origins. It simply scales the
* input's x and y values.
*
* @param pixelCoord A coordinate with x and y values in CSS pixel units.
* @returns The input coordinate divided by the workspace scale.
*/
private pixelsToWorkspaceUnits_(pixelCoord: Coordinate): Coordinate {
const result = new Coordinate(
pixelCoord.x / this.workspace.scale,
pixelCoord.y / this.workspace.scale,
);
if (this.workspace.isMutator) {
// If we're in a mutator, its scale is always 1, purely because of some
// oddities in our rendering optimizations. The actual scale is the same
// as the scale on the parent workspace. Fix that for dragging.
const mainScale = this.workspace.options.parentWorkspace!.scale;
result.scale(1 / mainScale);
}
return result;
}
}

View File

@@ -5,6 +5,7 @@
*/
import * as browserEvents from '../browser_events.js';
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
import {IBubble} from '../interfaces/i_bubble.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Scrollbar} from '../scrollbar.js';
@@ -15,13 +16,16 @@ import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import * as common from '../common.js';
import {ISelectable} from '../blockly.js';
import * as idGenerator from '../utils/idgenerator.js';
/**
* The abstract pop-up bubble class. This creates a UI that looks like a speech
* bubble, where it has a "tail" that points to the block, and a "head" that
* displays arbitrary svg elements.
*/
export abstract class Bubble implements IBubble {
export abstract class Bubble implements IBubble, ISelectable {
/** The width of the border around the bubble. */
static readonly BORDER_WIDTH = 6;
@@ -49,6 +53,8 @@ export abstract class Bubble implements IBubble {
/** Distance between arrow point and anchor point. */
static readonly ANCHOR_RADIUS = 8;
public id: string;
/** The SVG group containing all parts of the bubble. */
protected svgRoot: SVGGElement;
@@ -78,6 +84,8 @@ export abstract class Bubble implements IBubble {
/** The position of the left of the bubble realtive to its anchor. */
private relativeLeft = 0;
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -86,11 +94,16 @@ export abstract class Bubble implements IBubble {
* when automatically positioning.
*/
constructor(
protected readonly workspace: WorkspaceSvg,
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {}, workspace.getBubbleCanvas());
this.id = idGenerator.getNextUniqueId();
this.svgRoot = dom.createSvgElement(
Svg.G,
{'class': 'blocklyBubble'},
workspace.getBubbleCanvas(),
);
const embossGroup = dom.createSvgElement(
Svg.G,
{
@@ -100,7 +113,11 @@ export abstract class Bubble implements IBubble {
},
this.svgRoot,
);
this.tail = dom.createSvgElement(Svg.PATH, {}, embossGroup);
this.tail = dom.createSvgElement(
Svg.PATH,
{'class': 'blocklyBubbleTail'},
embossGroup,
);
this.background = dom.createSvgElement(
Svg.RECT,
{
@@ -198,6 +215,7 @@ export abstract class Bubble implements IBubble {
/** Passes the pointer event off to the gesture system. */
private onMouseDown(e: PointerEvent) {
this.workspace.getGesture(e)?.handleBubbleStart(e, this);
common.setSelected(this);
}
/** Positions the bubble relative to its anchor. Does not render its tail. */
@@ -604,4 +622,37 @@ export abstract class Bubble implements IBubble {
showContextMenu(_e: Event) {
// NOOP in base class.
}
/** Returns whether this bubble is movable or not. */
isMovable(): boolean {
return true;
}
/** Starts a drag on the bubble. */
startDrag(): void {
this.dragStrategy.startDrag();
}
/** Drags the bubble to the given location. */
drag(newLoc: Coordinate): void {
this.dragStrategy.drag(newLoc);
}
/** Ends the drag on the bubble. */
endDrag(): void {
this.dragStrategy.endDrag();
}
/** Moves the bubble back to where it was at the start of a drag. */
revertDrag(): void {
this.dragStrategy.revertDrag();
}
select(): void {
// Bubbles don't have any visual for being selected.
}
unselect(): void {
// Bubbles don't have any visual for being selected.
}
}

View File

@@ -47,7 +47,7 @@ export class MiniWorkspaceBubble extends Bubble {
/** @internal */
constructor(
workspaceOptions: BlocklyOptions,
protected readonly workspace: WorkspaceSvg,
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
) {

View File

@@ -20,7 +20,7 @@ export class TextBubble extends Bubble {
constructor(
private text: string,
protected readonly workspace: WorkspaceSvg,
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
) {

View File

@@ -70,15 +70,16 @@ export class TextInputBubble extends Bubble {
* when automatically positioning.
*/
constructor(
protected readonly workspace: WorkspaceSvg,
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
) {
super(workspace, anchor, ownerRect);
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
({inputRoot: this.inputRoot, textArea: this.textArea} = this.createEditor(
this.contentContainer,
));
this.resizeGroup = this.createResizeHandle(this.svgRoot);
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
}
@@ -126,7 +127,7 @@ export class TextInputBubble extends Bubble {
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
textArea.className = 'blocklyCommentTextarea';
textArea.className = 'blocklyTextarea blocklyText';
textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
body.appendChild(textArea);
@@ -158,51 +159,27 @@ export class TextInputBubble extends Bubble {
}
/** Creates the resize handler elements and binds events to them. */
private createResizeHandle(container: SVGGElement): SVGGElement {
const resizeGroup = dom.createSvgElement(
Svg.G,
private createResizeHandle(
container: SVGGElement,
workspace: WorkspaceSvg,
): SVGGElement {
const resizeHandle = dom.createSvgElement(
Svg.IMAGE,
{
'class': this.workspace.RTL ? 'blocklyResizeSW' : 'blocklyResizeSE',
'class': 'blocklyResizeHandle',
'href': `${workspace.options.pathToMedia}resize-handle.svg`,
},
container,
);
const size = 2 * Bubble.BORDER_WIDTH;
dom.createSvgElement(
Svg.POLYGON,
{'points': `0,${size} ${size},${size} ${size},0`},
resizeGroup,
);
dom.createSvgElement(
Svg.LINE,
{
'class': 'blocklyResizeLine',
'x1': size / 3,
'y1': size - 1,
'x2': size - 1,
'y2': size / 3,
},
resizeGroup,
);
dom.createSvgElement(
Svg.LINE,
{
'class': 'blocklyResizeLine',
'x1': (size * 2) / 3,
'y1': size - 1,
'x2': size - 1,
'y2': (size * 2) / 3,
},
resizeGroup,
);
browserEvents.conditionalBind(
resizeGroup,
resizeHandle,
'pointerdown',
this,
this.onResizePointerDown,
);
return resizeGroup;
return resizeHandle;
}
/**
@@ -330,8 +307,8 @@ export class TextInputBubble extends Bubble {
}
Css.register(`
.blocklyCommentTextarea {
background-color: #fef49c;
.blocklyTextInputBubble .blocklyTextarea {
background-color: var(--commentFillColour);
border: 0;
display: block;
margin: 0;

View File

@@ -6,7 +6,7 @@
// Former goog.module ID: Blockly.bumpObjects
import type {BlockSvg} from './block_svg.js';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import type {Abstract} from './events/events_abstract.js';
import type {BlockCreate} from './events/events_block_create.js';
import type {BlockMove} from './events/events_block_move.js';
@@ -17,7 +17,6 @@ import * as eventUtils from './events/utils.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {ContainerRegion} from './metrics_manager.js';
import * as mathUtils from './utils/math.js';
import type {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
@@ -152,7 +151,7 @@ export function bumpIntoBoundsHandler(
function extractObjectFromEvent(
workspace: WorkspaceSvg,
e: eventUtils.BumpEvent,
): BlockSvg | null | WorkspaceCommentSvg {
): IBoundedElement | null {
let object = null;
switch (e.type) {
case eventUtils.BLOCK_CREATE:
@@ -166,7 +165,7 @@ function extractObjectFromEvent(
case eventUtils.COMMENT_MOVE:
object = workspace.getCommentById(
(e as CommentCreate | CommentMove).commentId!,
) as WorkspaceCommentSvg | null;
) as RenderedWorkspaceComment;
break;
}
return object;

View File

@@ -12,30 +12,12 @@ import * as globalRegistry from './registry.js';
import {WorkspaceSvg} from './workspace_svg.js';
import * as registry from './clipboard/registry.js';
import {Coordinate} from './utils/coordinate.js';
import * as deprecation from './utils/deprecation.js';
/** Metadata about the object that is currently on the clipboard. */
let stashedCopyData: ICopyData | null = null;
let stashedWorkspace: WorkspaceSvg | null = null;
/**
* Copy a copyable element onto the local clipboard.
*
* @param toCopy The copyable element to be copied.
* @deprecated v11. Use `myCopyable.toCopyData()` instead. To be removed v12.
* @internal
*/
export function copy<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
deprecation.warn(
'Blockly.clipboard.copy',
'v11',
'v12',
'myCopyable.toCopyData()',
);
return TEST_ONLY.copyInternal(toCopy);
}
/**
* Private version of copy for stubbing in tests.
*/
@@ -107,29 +89,6 @@ function pasteFromData<T extends ICopyData>(
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
}
/**
* Duplicate this copy-paste-able element.
*
* @param toDuplicate The element to be duplicated.
* @returns The element that was duplicated, or null if the duplication failed.
* @deprecated v11. Use
* `Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)` instead.
* To be removed v12.
* @internal
*/
export function duplicate<
U extends ICopyData,
T extends ICopyable<U> & IHasWorkspace,
>(toDuplicate: T): T | null {
deprecation.warn(
'Blockly.clipboard.duplicate',
'v11',
'v12',
'Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)',
);
return TEST_ONLY.duplicateInternal(toDuplicate);
}
/**
* Private version of duplicate for stubbing in tests.
*/

View File

@@ -13,6 +13,7 @@ import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import * as eventUtils from '../events/utils.js';
import {config} from '../config.js';
import * as common from '../common.js';
export class BlockPaster implements IPaster<BlockCopyData, BlockSvg> {
static TYPE = 'block';
@@ -43,7 +44,7 @@ export class BlockPaster implements IPaster<BlockCopyData, BlockSvg> {
if (eventUtils.isEnabled() && !block.isShadow()) {
eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block));
}
block.select();
common.setSelected(block);
return block;
}
}

View File

@@ -8,11 +8,14 @@ import {IPaster} from '../interfaces/i_paster.js';
import {ICopyData} from '../interfaces/i_copyable.js';
import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {WorkspaceCommentSvg} from '../workspace_comment_svg.js';
import * as registry from './registry.js';
import * as commentSerialiation from '../serialization/workspace_comments.js';
import * as eventUtils from '../events/utils.js';
import * as common from '../common.js';
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
export class WorkspaceCommentPaster
implements IPaster<WorkspaceCommentCopyData, WorkspaceCommentSvg>
implements IPaster<WorkspaceCommentCopyData, RenderedWorkspaceComment>
{
static TYPE = 'workspace-comment';
@@ -20,26 +23,72 @@ export class WorkspaceCommentPaster
copyData: WorkspaceCommentCopyData,
workspace: WorkspaceSvg,
coordinate?: Coordinate,
): WorkspaceCommentSvg {
): RenderedWorkspaceComment | null {
const state = copyData.commentState;
if (coordinate) {
state.setAttribute('x', `${coordinate.x}`);
state.setAttribute('y', `${coordinate.y}`);
} else {
const x = parseInt(state.getAttribute('x') ?? '0') + 50;
const y = parseInt(state.getAttribute('y') ?? '0') + 50;
state.setAttribute('x', `${x}`);
state.setAttribute('y', `${y}`);
state['x'] = coordinate.x;
state['y'] = coordinate.y;
}
return WorkspaceCommentSvg.fromXmlRendered(
copyData.commentState,
workspace,
);
eventUtils.disable();
let comment;
try {
comment = commentSerialiation.append(
state,
workspace,
) as RenderedWorkspaceComment;
moveCommentToNotConflict(comment);
} finally {
eventUtils.enable();
}
if (!comment) return null;
if (eventUtils.isEnabled()) {
eventUtils.fire(new (eventUtils.get(eventUtils.COMMENT_CREATE))(comment));
}
common.setSelected(comment);
return comment;
}
}
function moveCommentToNotConflict(comment: RenderedWorkspaceComment) {
const workspace = comment.workspace;
const translateDistance = 30;
const coord = comment.getRelativeToSurfaceXY();
const offset = new Coordinate(0, 0);
// getRelativeToSurfaceXY is really expensive, so we want to cache this.
const otherCoords = workspace
.getTopComments(false)
.filter((otherComment) => otherComment.id !== comment.id)
.map((c) => c.getRelativeToSurfaceXY());
while (
commentOverlapsOtherExactly(Coordinate.sum(coord, offset), otherCoords)
) {
offset.translate(
workspace.RTL ? -translateDistance : translateDistance,
translateDistance,
);
}
comment.moveTo(Coordinate.sum(coord, offset));
}
function commentOverlapsOtherExactly(
coord: Coordinate,
otherCoords: Coordinate[],
): boolean {
return otherCoords.some(
(otherCoord) =>
Math.abs(otherCoord.x - coord.x) <= 1 &&
Math.abs(otherCoord.y - coord.y) <= 1,
);
}
export interface WorkspaceCommentCopyData extends ICopyData {
commentState: Element;
commentState: commentSerialiation.State;
}
registry.register(WorkspaceCommentPaster.TYPE, new WorkspaceCommentPaster());

9
core/comments.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export {CommentView} from './comments/comment_view.js';
export {WorkspaceComment} from './comments/workspace_comment.js';
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';

View File

@@ -0,0 +1,872 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import * as layers from '../layers.js';
import * as css from '../css.js';
import {Coordinate} from '../utils/coordinate.js';
import {Size} from '../utils/size.js';
import * as browserEvents from '../browser_events.js';
import * as touch from '../touch.js';
export class CommentView implements IRenderedElement {
/** The root group element of the comment view. */
private svgRoot: SVGGElement;
/**
* The svg rect element that we use to create a hightlight around the comment.
*/
private highlightRect: SVGRectElement;
/** The group containing all of the top bar elements. */
private topBarGroup: SVGGElement;
/** The rect background for the top bar. */
private topBarBackground: SVGRectElement;
/** The delete icon that goes in the top bar. */
private deleteIcon: SVGImageElement;
/** The foldout icon that goes in the top bar. */
private foldoutIcon: SVGImageElement;
/** The text element that goes in the top bar. */
private textPreview: SVGTextElement;
/** The actual text node in the text preview. */
private textPreviewNode: Text;
/** The resize handle element. */
private resizeHandle: SVGImageElement;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
/** The text area where the user can type. */
private textArea: HTMLTextAreaElement;
/** The current size of the comment in workspace units. */
private size: Size = new Size(120, 100);
/** Whether the comment is collapsed or not. */
private collapsed: boolean = false;
/** Whether the comment is editable or not. */
private editable: boolean = true;
/** The current location of the comment in workspace coordinates. */
private location: Coordinate = new Coordinate(0, 0);
/** The current text of the comment. Updates on text area change. */
private text: string = '';
/** Listeners for changes to text. */
private textChangeListeners: Array<
(oldText: string, newText: string) => void
> = [];
/** Listeners for changes to size. */
private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> =
[];
/** Listeners for disposal. */
private disposeListeners: Array<() => void> = [];
/** Listeners for collapsing. */
private collapseChangeListeners: Array<(newCollapse: boolean) => void> = [];
/**
* Event data for the pointer up event on the resize handle. Used to
* unregister the listener.
*/
private resizePointerUpListener: browserEvents.Data | null = null;
/**
* Event data for the pointer move event on the resize handle. Used to
* unregister the listener.
*/
private resizePointerMoveListener: browserEvents.Data | null = null;
/** Whether this comment view is currently being disposed or not. */
private disposing = false;
/** Whether this comment view has been disposed or not. */
private disposed = false;
constructor(private readonly workspace: WorkspaceSvg) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable',
});
this.highlightRect = this.createHighlightRect(this.svgRoot);
({
topBarGroup: this.topBarGroup,
topBarBackground: this.topBarBackground,
deleteIcon: this.deleteIcon,
foldoutIcon: this.foldoutIcon,
textPreview: this.textPreview,
textPreviewNode: this.textPreviewNode,
} = this.createTopBar(this.svgRoot, workspace));
({foreignObject: this.foreignObject, textArea: this.textArea} =
this.createTextArea(this.svgRoot));
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
// TODO: Remove this comment before merging.
// I think we want comments to exist on the same layer as blocks.
workspace.getLayerManager()?.append(this, layers.BLOCK);
// Set size to the default size.
this.setSize(this.size);
// Set default transform (including inverted scale for RTL).
this.moveTo(new Coordinate(0, 0));
}
/**
* Creates the rect we use for highlighting the comment when it's selected.
*/
private createHighlightRect(svgRoot: SVGGElement): SVGRectElement {
return dom.createSvgElement(
Svg.RECT,
{'class': 'blocklyCommentHighlight'},
svgRoot,
);
}
/**
* Creates the top bar and the elements visually within it.
* Registers event listeners.
*/
private createTopBar(
svgRoot: SVGGElement,
workspace: WorkspaceSvg,
): {
topBarGroup: SVGGElement;
topBarBackground: SVGRectElement;
deleteIcon: SVGImageElement;
foldoutIcon: SVGImageElement;
textPreview: SVGTextElement;
textPreviewNode: Text;
} {
const topBarGroup = dom.createSvgElement(
Svg.G,
{
'class': 'blocklyCommentTopbar',
},
svgRoot,
);
const topBarBackground = dom.createSvgElement(
Svg.RECT,
{
'class': 'blocklyCommentTopbarBackground',
},
topBarGroup,
);
// TODO: Before merging, does this mean to override an individual image,
// folks need to replace the whole media folder?
const deleteIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyDeleteIcon',
'href': `${workspace.options.pathToMedia}delete-icon.svg`,
},
topBarGroup,
);
const foldoutIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyFoldoutIcon',
'href': `${workspace.options.pathToMedia}foldout-icon.svg`,
},
topBarGroup,
);
const textPreview = dom.createSvgElement(
Svg.TEXT,
{
'class': 'blocklyCommentPreview blocklyCommentText blocklyText',
},
topBarGroup,
);
const textPreviewNode = document.createTextNode('');
textPreview.appendChild(textPreviewNode);
// TODO(toychest): Triggering this on pointerdown means that we can't start
// drags on the foldout icon. We need to open up the gesture system
// to fix this.
browserEvents.conditionalBind(
foldoutIcon,
'pointerdown',
this,
this.onFoldoutDown,
);
browserEvents.conditionalBind(
deleteIcon,
'pointerdown',
this,
this.onDeleteDown,
);
return {
topBarGroup,
topBarBackground,
deleteIcon,
foldoutIcon,
textPreview,
textPreviewNode,
};
}
/**
* Creates the text area where users can type. Registers event listeners.
*/
private createTextArea(svgRoot: SVGGElement): {
foreignObject: SVGForeignObjectElement;
textArea: HTMLTextAreaElement;
} {
const foreignObject = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'class': 'blocklyCommentForeignObject',
},
svgRoot,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
dom.addClass(textArea, 'blocklyCommentText');
dom.addClass(textArea, 'blocklyTextarea');
dom.addClass(textArea, 'blocklyText');
body.appendChild(textArea);
foreignObject.appendChild(body);
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
return {foreignObject, textArea};
}
/** Creates the DOM elements for the comment resize handle. */
private createResizeHandle(
svgRoot: SVGGElement,
workspace: WorkspaceSvg,
): SVGImageElement {
const resizeHandle = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyResizeHandle',
'href': `${workspace.options.pathToMedia}resize-handle.svg`,
},
svgRoot,
);
browserEvents.conditionalBind(
resizeHandle,
'pointerdown',
this,
this.onResizePointerDown,
);
return resizeHandle;
}
/** Returns the root SVG group element of the comment view. */
getSvgRoot(): SVGGElement {
return this.svgRoot;
}
/** Returns the current size of the comment in workspace units. */
getSize(): Size {
return this.size;
}
/**
* Sets the size of the comment in workspace units, and updates the view
* elements to reflect the new size.
*/
setSize(size: Size) {
const topBarSize = this.topBarBackground.getBBox();
const deleteSize = this.deleteIcon.getBBox();
const foldoutSize = this.foldoutIcon.getBBox();
const textPreviewSize = this.textPreview.getBBox();
const resizeSize = this.resizeHandle.getBBox();
size = Size.max(
size,
this.calcMinSize(topBarSize, foldoutSize, deleteSize),
);
const oldSize = this.size;
this.size = size;
this.svgRoot.setAttribute('height', `${size.height}`);
this.svgRoot.setAttribute('width', `${size.width}`);
this.updateHighlightRect(size);
this.updateTopBarSize(size);
this.updateTextAreaSize(size, topBarSize);
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
this.updateTextPreviewSize(
size,
topBarSize,
textPreviewSize,
deleteSize,
resizeSize,
);
this.updateResizeHandlePosition(size, resizeSize);
this.onSizeChange(oldSize, this.size);
}
/**
* Calculates the minimum size for the uncollapsed comment based on text
* size and visible icons.
*
* The minimum width is based on the width of the truncated preview text.
*
* The minimum height is based on the height of the top bar.
*/
private calcMinSize(
topBarSize: Size,
foldoutSize: Size,
deleteSize: Size,
): Size {
this.updateTextPreview(this.textArea.value ?? '');
const textPreviewWidth = dom.getTextWidth(this.textPreview);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
let width = textPreviewWidth;
if (this.foldoutIcon.checkVisibility()) {
width += foldoutSize.width + foldoutMargin * 2;
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin before text.
}
if (this.deleteIcon.checkVisibility()) {
width += deleteSize.width + deleteMargin * 2;
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin after text.
}
// Arbitrary additional height.
const height = topBarSize.height + 20;
return new Size(width, height);
}
/** Calculates the margin that should exist around the delete icon. */
private calcDeleteMargin(topBarSize: Size, deleteSize: Size) {
return (topBarSize.height - deleteSize.height) / 2;
}
/** Calculates the margin that should exist around the foldout icon. */
private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) {
return (topBarSize.height - foldoutSize.height) / 2;
}
/** Updates the size of the highlight rect to reflect the new size. */
private updateHighlightRect(size: Size) {
this.highlightRect.setAttribute('height', `${size.height}`);
this.highlightRect.setAttribute('width', `${size.width}`);
if (this.workspace.RTL) {
this.highlightRect.setAttribute('x', `${-size.width}`);
}
}
/** Updates the size of the top bar to reflect the new size. */
private updateTopBarSize(size: Size) {
this.topBarBackground.setAttribute('width', `${size.width}`);
}
/** Updates the size of the text area elements to reflect the new size. */
private updateTextAreaSize(size: Size, topBarSize: Size) {
this.foreignObject.setAttribute(
'height',
`${size.height - topBarSize.height}`,
);
this.foreignObject.setAttribute('width', `${size.width}`);
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
if (this.workspace.RTL) {
this.foreignObject.setAttribute('x', `${-size.width}`);
}
this.textArea.style.width = `${size.width}px`;
this.textArea.style.height = `${size.height}px`;
}
/**
* Updates the position of the delete icon elements to reflect the new size.
*/
private updateDeleteIconPosition(
size: Size,
topBarSize: Size,
deleteSize: Size,
) {
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
this.deleteIcon.setAttribute('y', `${deleteMargin}`);
this.deleteIcon.setAttribute(
'x',
`${size.width - deleteSize.width - deleteMargin}`,
);
}
/**
* Updates the position of the foldout icon elements to reflect the new size.
*/
private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
}
/**
* Updates the size and position of the text preview elements to reflect the new size.
*/
private updateTextPreviewSize(
size: Size,
topBarSize: Size,
textPreviewSize: Size,
deleteSize: Size,
foldoutSize: Size,
) {
const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const textPreviewWidth =
size.width -
foldoutSize.width -
foldoutMargin * 2 -
deleteSize.width -
deleteMargin * 2;
this.textPreview.setAttribute(
'x',
`${
foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
}`,
);
this.textPreview.setAttribute(
'y',
`${textPreviewMargin + textPreviewSize.height / 2}`,
);
this.textPreview.setAttribute('width', `${textPreviewWidth}`);
}
/** Updates the position of the resize handle to reflect the new size. */
private updateResizeHandlePosition(size: Size, resizeSize: Size) {
this.resizeHandle.setAttribute('y', `${size.height - resizeSize.height}`);
this.resizeHandle.setAttribute('x', `${size.width - resizeSize.width}`);
}
/**
* Triggers listeners when the size of the comment changes, either
* progrmatically or manually by the user.
*/
private onSizeChange(oldSize: Size, newSize: Size) {
// Loop through listeners backwards in case they remove themselves.
for (let i = this.sizeChangeListeners.length - 1; i >= 0; i--) {
this.sizeChangeListeners[i](oldSize, newSize);
}
}
/**
* Registers a callback that listens for size changes.
*
* @param listener Receives callbacks when the size of the comment changes.
* The new and old size are in workspace units.
*/
addSizeChangeListener(listener: (oldSize: Size, newSize: Size) => void) {
this.sizeChangeListeners.push(listener);
}
/** Removes the given listener from the list of size change listeners. */
removeSizeChangeListener(listener: () => void) {
this.sizeChangeListeners.splice(
this.sizeChangeListeners.indexOf(listener),
1,
);
}
/**
* Handles starting an interaction with the resize handle to resize the
* comment.
*/
private onResizePointerDown(e: PointerEvent) {
if (!this.isEditable()) return;
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
// TODO(#7926): Move this into a utils file.
this.workspace.startDrag(
e,
new Coordinate(
this.workspace.RTL ? -this.getSize().width : this.getSize().width,
this.getSize().height,
),
);
this.resizePointerUpListener = browserEvents.conditionalBind(
document,
'pointerup',
this,
this.onResizePointerUp,
);
this.resizePointerMoveListener = browserEvents.conditionalBind(
document,
'pointermove',
this,
this.onResizePointerMove,
);
this.workspace.hideChaff();
e.stopPropagation();
}
/** Ends an interaction with the resize handle. */
private onResizePointerUp(_e: PointerEvent) {
touch.clearTouchIdentifier();
if (this.resizePointerUpListener) {
browserEvents.unbind(this.resizePointerUpListener);
this.resizePointerUpListener = null;
}
if (this.resizePointerMoveListener) {
browserEvents.unbind(this.resizePointerMoveListener);
this.resizePointerMoveListener = null;
}
}
/** Resizes the comment in response to a drag on the resize handle. */
private onResizePointerMove(e: PointerEvent) {
// TODO(#7926): Move this into a utils file.
const delta = this.workspace.moveDrag(e);
this.setSize(new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y));
}
/** Returns true if the comment is currently collapsed. */
isCollapsed(): boolean {
return this.collapsed;
}
/** Sets whether the comment is currently collapsed or not. */
setCollapsed(collapsed: boolean) {
this.collapsed = collapsed;
if (collapsed) {
dom.addClass(this.svgRoot, 'blocklyCollapsed');
} else {
dom.removeClass(this.svgRoot, 'blocklyCollapsed');
}
// Repositions resize handle and such.
this.setSize(this.size);
this.onCollapse();
}
/**
* Triggers listeners when the collapsed-ness of the comment changes, either
* progrmatically or manually by the user.
*/
private onCollapse() {
// Loop through listeners backwards in case they remove themselves.
for (let i = this.collapseChangeListeners.length - 1; i >= 0; i--) {
this.collapseChangeListeners[i](this.collapsed);
}
}
/** Registers a callback that listens for collapsed-ness changes. */
addOnCollapseListener(listener: (newCollapse: boolean) => void) {
this.collapseChangeListeners.push(listener);
}
/** Removes the given listener from the list of on collapse listeners. */
removeOnCollapseListener(listener: () => void) {
this.collapseChangeListeners.splice(
this.collapseChangeListeners.indexOf(listener),
1,
);
}
/**
* Toggles the collapsedness of the block when we receive a pointer down
* event on the foldout icon.
*/
private onFoldoutDown(e: PointerEvent) {
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.setCollapsed(!this.collapsed);
this.workspace.hideChaff();
e.stopPropagation();
}
/** Returns true if the comment is currently editable. */
isEditable(): boolean {
return this.editable;
}
/** Sets the editability of the comment. */
setEditable(editable: boolean) {
this.editable = editable;
if (this.editable) {
dom.addClass(this.svgRoot, 'blocklyEditable');
dom.removeClass(this.svgRoot, 'blocklyReadonly');
this.textArea.removeAttribute('readonly');
} else {
dom.removeClass(this.svgRoot, 'blocklyEditable');
dom.addClass(this.svgRoot, 'blocklyReadonly');
this.textArea.setAttribute('readonly', 'true');
}
}
/** Returns the current location of the comment in workspace coordinates. */
getRelativeToSurfaceXY(): Coordinate {
return this.location;
}
/**
* Moves the comment view to the given location.
*
* @param location The location to move to in workspace coordinates.
*/
moveTo(location: Coordinate) {
this.location = location;
this.svgRoot.setAttribute(
'transform',
`translate(${location.x}, ${location.y})`,
);
}
/** Retursn the current text of the comment. */
getText() {
return this.text;
}
/** Sets the current text of the comment. */
setText(text: string) {
this.textArea.value = text;
this.onTextChange();
}
/** Registers a callback that listens for text changes. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener);
}
/** Removes the given listener from the list of text change listeners. */
removeTextChangeListener(listener: () => void) {
this.textChangeListeners.splice(
this.textChangeListeners.indexOf(listener),
1,
);
}
/**
* Triggers listeners when the text of the comment changes, either
* progrmatically or manually by the user.
*/
private onTextChange() {
const oldText = this.text;
this.text = this.textArea.value;
this.updateTextPreview(this.text);
// Update size in case our minimum size increased.
this.setSize(this.size);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
this.textChangeListeners[i](oldText, this.text);
}
}
/** Updates the preview text element to reflect the given text. */
private updateTextPreview(text: string) {
this.textPreviewNode.textContent = this.truncateText(text);
}
/** Truncates the text to fit within the top view. */
private truncateText(text: string): string {
return text.length >= 12 ? `${text.substring(0, 9)}...` : text;
}
/** Brings the workspace comment to the front of its layer. */
private bringToFront() {
const parent = this.svgRoot.parentNode;
const childNodes = parent!.childNodes;
// Avoid moving the comment if it's already at the bottom.
if (childNodes[childNodes.length - 1] !== this.svgRoot) {
parent!.appendChild(this.svgRoot);
}
}
/**
* Handles disposing of the comment when we get a pointer down event on the
* delete icon.
*/
private onDeleteDown(e: PointerEvent) {
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.dispose();
e.stopPropagation();
}
/** Disposes of this comment view. */
dispose() {
this.disposing = true;
dom.removeNode(this.svgRoot);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
this.disposeListeners[i]();
}
this.disposed = true;
}
/** Returns whether this comment view has been disposed or not. */
isDisposed(): boolean {
return this.disposed;
}
/**
* Returns true if this comment view is currently being disposed or has
* already been disposed.
*/
isDeadOrDying(): boolean {
return this.disposing || this.disposed;
}
/** Registers a callback that listens for disposal of this view. */
addDisposeListener(listener: () => void) {
this.disposeListeners.push(listener);
}
/** Removes the given listener from the list of disposal listeners. */
removeDisposeListener(listener: () => void) {
this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
}
}
css.register(`
.injectionDiv {
--commentFillColour: #FFFCC7;
--commentBorderColour: #F2E49B;
}
.blocklyComment .blocklyTextarea {
background-color: var(--commentFillColour);
border: 1px solid var(--commentBorderColour);
outline: 0;
resize: none;
overflow: hidden;
box-sizing: border-box;
padding: 8px;
width: 100%;
height: 100%;
display: block;
}
.blocklyReadonly.blocklyComment .blocklyTextarea {
cursor: inherit;
}
.blocklyDeleteIcon {
width: 20px;
height: 20px;
display: none;
cursor: pointer;
}
.blocklyFoldoutIcon {
width: 20px;
height: 20px;
transform-origin: 12px 12px;
cursor: pointer;
}
.blocklyResizeHandle {
width: 12px;
height: 12px;
cursor: se-resize;
}
.blocklyReadonly.blocklyComment .blocklyResizeHandle {
cursor: inherit;
}
.blocklyCommentTopbarBackground {
fill: var(--commentBorderColour);
height: 24px;
}
.blocklyComment .blocklyCommentPreview.blocklyText {
fill: #000;
dominant-baseline: middle;
visibility: hidden;
}
.blocklyCollapsed.blocklyComment .blocklyCommentPreview {
visibility: visible;
}
.blocklyCollapsed.blocklyComment .blocklyCommentForeignObject,
.blocklyCollapsed.blocklyComment .blocklyResizeHandle {
display: none;
}
.blocklyCollapsed.blocklyComment .blocklyFoldoutIcon {
transform: rotate(-90deg);
}
.blocklyRTL .blocklyCommentTopbar {
transform: scale(-1, 1);
}
.blocklyRTL .blocklyCommentForeignObject {
direction: rtl;
}
.blocklyRTL .blocklyCommentPreview {
/* Revert the scale and control RTL using direction instead. */
transform: scale(-1, 1);
direction: rtl;
}
.blocklyRTL .blocklyResizeHandle {
transform: scale(-1, 1);
cursor: sw-resize;
}
.blocklyCommentHighlight {
fill: none;
}
.blocklySelected .blocklyCommentHighlight {
stroke: #fc3;
stroke-width: 3px;
}
.blocklyCollapsed.blocklySelected .blocklyCommentHighlight {
stroke: none;
}
.blocklyCollapsed.blocklySelected .blocklyCommentTopbarBackground {
stroke: #fc3;
stroke-width: 3px;
}
`);

View File

@@ -0,0 +1,264 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {WorkspaceComment} from './workspace_comment.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {CommentView} from './comment_view.js';
import {Coordinate} from '../utils/coordinate.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {IBoundedElement} from '../interfaces/i_bounded_element.js';
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import * as dom from '../utils/dom.js';
import {IDraggable} from '../interfaces/i_draggable.js';
import {CommentDragStrategy} from '../dragging/comment_drag_strategy.js';
import * as browserEvents from '../browser_events.js';
import * as common from '../common.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {IDeletable} from '../interfaces/i_deletable.js';
import {ICopyable} from '../interfaces/i_copyable.js';
import * as commentSerialization from '../serialization/workspace_comments.js';
import {
WorkspaceCommentPaster,
WorkspaceCommentCopyData,
} from '../clipboard/workspace_comment_paster.js';
import {IContextMenu} from '../interfaces/i_contextmenu.js';
import * as contextMenu from '../contextmenu.js';
import {ContextMenuRegistry} from '../contextmenu_registry.js';
export class RenderedWorkspaceComment
extends WorkspaceComment
implements
IBoundedElement,
IRenderedElement,
IDraggable,
ISelectable,
IDeletable,
ICopyable<WorkspaceCommentCopyData>,
IContextMenu
{
/** The class encompassing the svg elements making up the workspace comment. */
private view: CommentView;
public readonly workspace: WorkspaceSvg;
private dragStrategy = new CommentDragStrategy(this);
/** Constructs the workspace comment, including the view. */
constructor(workspace: WorkspaceSvg, id?: string) {
super(workspace, id);
this.workspace = workspace;
this.view = new CommentView(workspace);
// Set the size to the default size as defined in the superclass.
this.view.setSize(this.getSize());
this.view.setEditable(this.isEditable());
this.addModelUpdateBindings();
browserEvents.conditionalBind(
this.view.getSvgRoot(),
'pointerdown',
this,
this.startGesture,
);
}
/**
* Adds listeners to the view that updates the model (i.e. the superclass)
* when changes are made to the view.
*/
private addModelUpdateBindings() {
this.view.addTextChangeListener(
(_, newText: string) => void super.setText(newText),
);
this.view.addSizeChangeListener(
(_, newSize: Size) => void super.setSize(newSize),
);
this.view.addOnCollapseListener(
() => void super.setCollapsed(this.view.isCollapsed()),
);
this.view.addDisposeListener(() => {
if (!this.isDeadOrDying()) this.dispose();
});
}
/** Sets the text of the comment. */
override setText(text: string): void {
// setText will trigger the change listener that updates
// the model aka superclass.
this.view.setText(text);
}
/** Sets the size of the comment. */
override setSize(size: Size) {
// setSize will trigger the change listener that updates
// the model aka superclass.
this.view.setSize(size);
}
/** Sets whether the comment is collapsed or not. */
override setCollapsed(collapsed: boolean) {
// setCollapsed will trigger the change listener that updates
// the model aka superclass.
this.view.setCollapsed(collapsed);
}
/** Sets whether the comment is editable or not. */
override setEditable(editable: boolean): void {
super.setEditable(editable);
// Use isEditable rather than isOwnEditable to account for workspace state.
this.view.setEditable(this.isEditable());
}
/** Returns the root SVG element of this comment. */
getSvgRoot(): SVGElement {
return this.view.getSvgRoot();
}
/** Returns the bounding rectangle of this comment in workspace coordinates. */
getBoundingRectangle(): Rect {
const loc = this.getRelativeToSurfaceXY();
const size = this.getSize();
return new Rect(loc.y, loc.y + size.height, loc.x, loc.x + size.width);
}
/** Move the comment by the given amounts in workspace coordinates. */
moveBy(dx: number, dy: number, reason?: string[] | undefined): void {
const loc = this.getRelativeToSurfaceXY();
const newLoc = new Coordinate(loc.x + dx, loc.y + dy);
this.moveTo(newLoc, reason);
}
/** Moves the comment to the given location in workspace coordinates. */
override moveTo(location: Coordinate, reason?: string[] | undefined): void {
super.moveTo(location, reason);
this.view.moveTo(location);
this.snapToGrid();
}
/**
* Moves the comment during a drag. Doesn't fire move events.
*
* @internal
*/
moveDuringDrag(location: Coordinate): void {
this.location = location;
this.view.moveTo(location);
}
/**
* Adds the dragging CSS class to this comment.
*
* @internal
*/
setDragging(dragging: boolean): void {
if (dragging) {
dom.addClass(this.getSvgRoot(), 'blocklyDragging');
} else {
dom.removeClass(this.getSvgRoot(), 'blocklyDragging');
}
}
/** Disposes of the view. */
override dispose() {
this.disposing = true;
if (!this.view.isDeadOrDying()) this.view.dispose();
super.dispose();
}
/**
* Starts a gesture because we detected a pointer down on the comment
* (that wasn't otherwise gobbled up, e.g. by resizing).
*/
private startGesture(e: PointerEvent) {
const gesture = this.workspace.getGesture(e);
if (gesture) {
gesture.handleCommentStart(e, this);
common.setSelected(this);
}
}
/** Visually indicates that this comment would be deleted if dropped. */
setDeleteStyle(wouldDelete: boolean): void {
if (wouldDelete) {
dom.addClass(this.getSvgRoot(), 'blocklyDraggingDelete');
} else {
dom.removeClass(this.getSvgRoot(), 'blocklyDraggingDelete');
}
}
/** Returns whether this comment is movable or not. */
isMovable(): boolean {
return this.dragStrategy.isMovable();
}
/** Starts a drag on the comment. */
startDrag(): void {
this.dragStrategy.startDrag();
}
/** Drags the comment to the given location. */
drag(newLoc: Coordinate): void {
this.dragStrategy.drag(newLoc);
}
/** Ends the drag on the comment. */
endDrag(): void {
this.snapToGrid();
this.dragStrategy.endDrag();
}
/** Moves the comment back to where it was at the start of a drag. */
revertDrag(): void {
this.dragStrategy.revertDrag();
}
/** Visually highlights the comment. */
select(): void {
dom.addClass(this.getSvgRoot(), 'blocklySelected');
}
/** Visually unhighlights the comment. */
unselect(): void {
dom.removeClass(this.getSvgRoot(), 'blocklySelected');
}
/**
* Returns a JSON serializable representation of this comment's state that
* can be used for pasting.
*/
toCopyData(): WorkspaceCommentCopyData | null {
return {
paster: WorkspaceCommentPaster.TYPE,
commentState: commentSerialization.save(this, {
addCoordinates: true,
}),
};
}
/** Show a context menu for this comment. */
showContextMenu(e: PointerEvent): void {
const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
ContextMenuRegistry.ScopeType.COMMENT,
{comment: this},
);
contextMenu.show(e, menuOptions, this.workspace.RTL);
}
/** Snap this comment to the nearest grid point. */
snapToGrid(): void {
if (this.isDeadOrDying()) return;
const grid = this.workspace.getGrid();
if (!grid?.shouldSnap()) return;
const currentXY = this.getRelativeToSurfaceXY();
const alignedXY = grid.alignXY(currentXY);
if (alignedXY !== currentXY) {
this.moveTo(alignedXY, ['snap']);
}
}
}

View File

@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Workspace} from '../workspace.js';
import {Size} from '../utils/size.js';
import {Coordinate} from '../utils/coordinate.js';
import * as idGenerator from '../utils/idgenerator.js';
import * as eventUtils from '../events/utils.js';
import {CommentMove} from '../events/events_comment_move.js';
export class WorkspaceComment {
/** The unique identifier for this comment. */
public readonly id: string;
/** The text of the comment. */
private text = '';
/** The size of the comment in workspace units. */
private size = new Size(120, 100);
/** Whether the comment is collapsed or not. */
private collapsed = false;
/** Whether the comment is editable or not. */
private editable = true;
/** Whether the comment is movable or not. */
private movable = true;
/** Whether the comment is deletable or not. */
private deletable = true;
/** The location of the comment in workspace coordinates. */
protected location = new Coordinate(0, 0);
/** Whether this comment has been disposed or not. */
protected disposed = false;
/** Whether this comment is being disposed or not. */
protected disposing = false;
/**
* Constructs the comment.
*
* @param workspace The workspace to construct the comment in.
* @param id An optional ID to give to the comment. If not provided, one will
* be generated.
*/
constructor(
public readonly workspace: Workspace,
id?: string,
) {
this.id = id && !workspace.getCommentById(id) ? id : idGenerator.genUid();
workspace.addTopComment(this);
this.fireCreateEvent();
}
private fireCreateEvent() {
if (eventUtils.isEnabled()) {
eventUtils.fire(new (eventUtils.get(eventUtils.COMMENT_CREATE))(this));
}
}
private fireDeleteEvent() {
if (eventUtils.isEnabled()) {
eventUtils.fire(new (eventUtils.get(eventUtils.COMMENT_DELETE))(this));
}
}
/** Fires a comment change event. */
private fireChangeEvent(oldText: string, newText: string) {
if (eventUtils.isEnabled()) {
eventUtils.fire(
new (eventUtils.get(eventUtils.COMMENT_CHANGE))(this, oldText, newText),
);
}
}
/** Fires a comment collapse event. */
private fireCollapseEvent(newCollapsed: boolean) {
if (eventUtils.isEnabled()) {
eventUtils.fire(
new (eventUtils.get(eventUtils.COMMENT_COLLAPSE))(this, newCollapsed),
);
}
}
/** Sets the text of the comment. */
setText(text: string) {
const oldText = this.text;
this.text = text;
this.fireChangeEvent(oldText, text);
}
/** Returns the text of the comment. */
getText(): string {
return this.text;
}
/** Sets the comment's size in workspace units. */
setSize(size: Size) {
this.size = size;
}
/** Returns the comment's size in workspace units. */
getSize(): Size {
return this.size;
}
/** Sets whether the comment is collapsed or not. */
setCollapsed(collapsed: boolean) {
this.collapsed = collapsed;
this.fireCollapseEvent(collapsed);
}
/** Returns whether the comment is collapsed or not. */
isCollapsed(): boolean {
return this.collapsed;
}
/** Sets whether the comment is editable or not. */
setEditable(editable: boolean) {
this.editable = editable;
}
/**
* Returns whether the comment is editable or not, respecting whether the
* workspace is read-only.
*/
isEditable(): boolean {
return this.isOwnEditable() && !this.workspace.options.readOnly;
}
/**
* Returns whether the comment is editable or not, only examining its own
* state and ignoring the state of the workspace.
*/
isOwnEditable(): boolean {
return this.editable;
}
/** Sets whether the comment is movable or not. */
setMovable(movable: boolean) {
this.movable = movable;
}
/**
* Returns whether the comment is movable or not, respecting whether the
* workspace is read-only.
*/
isMovable() {
return this.isOwnMovable() && !this.workspace.options.readOnly;
}
/**
* Returns whether the comment is movable or not, only examining its own
* state and ignoring the state of the workspace.
*/
isOwnMovable() {
return this.movable;
}
/** Sets whether the comment is deletable or not. */
setDeletable(deletable: boolean) {
this.deletable = deletable;
}
/**
* Returns whether the comment is deletable or not, respecting whether the
* workspace is read-only.
*/
isDeletable(): boolean {
return this.isOwnDeletable() && !this.workspace.options.readOnly;
}
/**
* Returns whether the comment is deletable or not, only examining its own
* state and ignoring the state of the workspace.
*/
isOwnDeletable(): boolean {
return this.deletable;
}
/** Moves the comment to the given location in workspace coordinates. */
moveTo(location: Coordinate, reason?: string[] | undefined) {
const event = new (eventUtils.get(eventUtils.COMMENT_MOVE))(
this,
) as CommentMove;
if (reason) event.setReason(reason);
this.location = location;
event.recordNew();
if (eventUtils.isEnabled()) eventUtils.fire(event);
}
/** Returns the position of the comment in workspace coordinates. */
getRelativeToSurfaceXY(): Coordinate {
return this.location;
}
/** Disposes of this comment. */
dispose() {
this.disposing = true;
this.fireDeleteEvent();
this.workspace.removeTopComment(this);
this.disposed = true;
}
/** Returns whether the comment has been disposed or not. */
isDisposed() {
return this.disposed;
}
/**
* Returns true if this comment view is currently being disposed or has
* already been disposed.
*/
isDeadOrDying(): boolean {
return this.disposing || this.disposed;
}
}

View File

@@ -13,6 +13,7 @@ import {BlockDefinition, Blocks} from './blocks.js';
import type {Connection} from './connection.js';
import type {Workspace} from './workspace.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as eventUtils from './events/utils.js';
/** Database of all workspaces. */
const WorkspaceDB_ = Object.create(null);
@@ -105,7 +106,18 @@ export function getSelected(): ISelectable | null {
* @internal
*/
export function setSelected(newSelection: ISelectable | null) {
if (selected === newSelection) return;
const event = new (eventUtils.get(eventUtils.SELECTED))(
selected?.id ?? null,
newSelection?.id ?? null,
newSelection?.workspace.id ?? selected?.workspace.id ?? '',
);
eventUtils.fire(event);
selected?.unselect();
selected = newSelection;
selected?.select();
}
/**

View File

@@ -47,8 +47,6 @@ export const config: Config = {
/**
* Maximum misalignment between connections for them to snap together.
* This should be the same as the snap radius.
*
* @deprecated v11 - This is no longer used. Use snapRadius instead.
*/
connectingSnapRadius: DEFAULT_SNAP_RADIUS,
/**

View File

@@ -153,8 +153,10 @@ export class Connection implements IASTNodeLocationWithBlock {
dispose() {
// isConnected returns true for shadows and non-shadows.
if (this.isConnected()) {
// Destroy the attached shadow block & its children (if it exists).
this.setShadowStateInternal();
if (this.isSuperior()) {
// Destroy the attached shadow block & its children (if it exists).
this.setShadowStateInternal();
}
const targetBlock = this.targetBlock();
if (targetBlock && !targetBlock.isDeadOrDying()) {
@@ -600,6 +602,8 @@ export class Connection implements IASTNodeLocationWithBlock {
this.shadowDom = shadowDom;
this.shadowState = shadowState;
if (this.getSourceBlock().isDeadOrDying()) return;
const target = this.targetBlock();
if (!target) {
this.respawnShadow_();
@@ -608,7 +612,6 @@ export class Connection implements IASTNodeLocationWithBlock {
}
} else if (target.isShadow()) {
target.dispose(false);
if (this.getSourceBlock().isDeadOrDying()) return;
this.respawnShadow_();
if (this.targetBlock() && this.targetBlock()!.isShadow()) {
this.serializeShadow(this.targetBlock());

View File

@@ -15,3 +15,9 @@ export const COLLAPSED_INPUT_NAME = '_TEMP_COLLAPSED_INPUT';
* The language-neutral ID given to the collapsed field.
*/
export const COLLAPSED_FIELD_NAME = '_TEMP_COLLAPSED_FIELD';
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the user manually disabled it, such as via the context menu.
*/
export const MANUALLY_DISABLED = 'MANUALLY_DISABLED';

View File

@@ -9,7 +9,6 @@
import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as clipboard from './clipboard.js';
import {config} from './config.js';
import * as dom from './utils/dom.js';
import type {
@@ -19,16 +18,13 @@ import type {
import * as eventUtils from './events/utils.js';
import {Menu} from './menu.js';
import {MenuItem} from './menuitem.js';
import {Msg} from './msg.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import {Rect} from './utils/rect.js';
import * as serializationBlocks from './serialization/blocks.js';
import * as svgMath from './utils/svg_math.js';
import * as WidgetDiv from './widgetdiv.js';
import {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as Xml from './xml.js';
import * as common from './common.js';
/**
* Which block is the context menu attached to?
@@ -68,7 +64,7 @@ let menu_: Menu | null = null;
* @param rtl True if RTL, false if LTR.
*/
export function show(
e: Event,
e: PointerEvent,
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
) {
@@ -77,7 +73,7 @@ export function show(
hide();
return;
}
const menu = populate_(options, rtl);
const menu = populate_(options, rtl, e);
menu_ = menu;
position_(menu, e, rtl);
@@ -94,11 +90,13 @@ export function show(
*
* @param options Array of menu options.
* @param rtl True if RTL, false if LTR.
* @param e The event that triggered the context menu to open.
* @returns The menu that will be shown on right click.
*/
function populate_(
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
e: PointerEvent,
): Menu {
/* Here's what one option object looks like:
{text: 'Make It So',
@@ -123,7 +121,7 @@ function populate_(
// will not be expecting a scope parameter, so there should be
// no problems. Just assume it is a ContextMenuOption and we'll
// pass undefined if it's not.
option.callback((option as ContextMenuOption).scope);
option.callback((option as ContextMenuOption).scope, e);
}, 0);
});
};
@@ -261,129 +259,7 @@ export function callbackFactory(
if (eventUtils.isEnabled() && !newBlock.isShadow()) {
eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(newBlock));
}
newBlock.select();
common.setSelected(newBlock);
return newBlock;
};
}
// Helper functions for creating context menu options.
/**
* Make a context menu option for deleting the current workspace comment.
*
* @param comment The workspace comment where the
* right-click originated.
* @returns A menu option,
* containing text, enabled, and a callback.
* @internal
*/
export function commentDeleteOption(
comment: WorkspaceCommentSvg,
): LegacyContextMenuOption {
const deleteOption = {
text: Msg['REMOVE_COMMENT'],
enabled: true,
callback: function () {
eventUtils.setGroup(true);
comment.dispose();
eventUtils.setGroup(false);
},
};
return deleteOption;
}
/**
* Make a context menu option for duplicating the current workspace comment.
*
* @param comment The workspace comment where the
* right-click originated.
* @returns A menu option,
* containing text, enabled, and a callback.
* @internal
*/
export function commentDuplicateOption(
comment: WorkspaceCommentSvg,
): LegacyContextMenuOption {
const duplicateOption = {
text: Msg['DUPLICATE_COMMENT'],
enabled: true,
callback: function () {
const data = comment.toCopyData();
if (!data) return;
clipboard.paste(data, comment.workspace);
},
};
return duplicateOption;
}
/**
* Make a context menu option for adding a comment on the workspace.
*
* @param ws The workspace where the right-click
* originated.
* @param e The right-click mouse event.
* @returns A menu option, containing text, enabled, and a callback.
* comments are not bundled in.
* @internal
*/
export function workspaceCommentOption(
ws: WorkspaceSvg,
e: Event,
): ContextMenuOption {
/**
* Helper function to create and position a comment correctly based on the
* location of the mouse event.
*/
function addWsComment() {
const comment = new WorkspaceCommentSvg(
ws,
Msg['WORKSPACE_COMMENT_DEFAULT_TEXT'],
WorkspaceCommentSvg.DEFAULT_SIZE,
WorkspaceCommentSvg.DEFAULT_SIZE,
);
const injectionDiv = ws.getInjectionDiv();
// Bounding rect coordinates are in client coordinates, meaning that they
// are in pixels relative to the upper left corner of the visible browser
// window. These coordinates change when you scroll the browser window.
const boundingRect = injectionDiv.getBoundingClientRect();
// The client coordinates offset by the injection div's upper left corner.
const mouseEvent = e as MouseEvent;
const clientOffsetPixels = new Coordinate(
mouseEvent.clientX - boundingRect.left,
mouseEvent.clientY - boundingRect.top,
);
// The offset in pixels between the main workspace's origin and the upper
// left corner of the injection div.
const mainOffsetPixels = ws.getOriginOffsetInPixels();
// The position of the new comment in pixels relative to the origin of the
// main workspace.
const finalOffset = Coordinate.difference(
clientOffsetPixels,
mainOffsetPixels,
);
// The position of the new comment in main workspace coordinates.
finalOffset.scale(1 / ws.scale);
const commentX = finalOffset.x;
const commentY = finalOffset.y;
comment.moveBy(commentX, commentY);
if (ws.rendered) {
comment.initSvg();
comment.render();
comment.select();
}
}
const wsCommentOption = {
enabled: true,
} as ContextMenuOption;
wsCommentOption.text = Msg['ADD_COMMENT'];
wsCommentOption.callback = function () {
addWsComment();
};
return wsCommentOption;
}

View File

@@ -8,18 +8,22 @@
import type {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import {
ContextMenuRegistry,
RegistryItem,
Scope,
} from './contextmenu_registry.js';
import {MANUALLY_DISABLED} from './constants.js';
import * as dialog from './dialog.js';
import * as Events from './events/events.js';
import * as eventUtils from './events/utils.js';
import {CommentIcon} from './icons/comment_icon.js';
import {Msg} from './msg.js';
import {StatementInput} from './renderers/zelos/zelos.js';
import {Coordinate} from './utils/coordinate.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as common from './common.js';
/**
* Option to undo previous action.
@@ -455,9 +459,9 @@ export function registerCollapseExpandBlock() {
export function registerDisable() {
const disableOption: RegistryItem = {
displayText(scope: Scope) {
return scope.block!.isEnabled()
? Msg['DISABLE_BLOCK']
: Msg['ENABLE_BLOCK'];
return scope.block!.hasDisabledReason(MANUALLY_DISABLED)
? Msg['ENABLE_BLOCK']
: Msg['DISABLE_BLOCK'];
},
preconditionFn(scope: Scope) {
const block = scope.block;
@@ -466,7 +470,14 @@ export function registerDisable() {
block!.workspace.options.disable &&
block!.isEditable()
) {
if (block!.getInheritedDisabled()) {
// Determine whether this block is currently disabled for any reason
// other than the manual reason that this context menu item controls.
const disabledReasons = block!.getDisabledReasons();
const isDisabledForOtherReason =
disabledReasons.size >
(disabledReasons.has(MANUALLY_DISABLED) ? 1 : 0);
if (block!.getInheritedDisabled() || isDisabledForOtherReason) {
return 'disabled';
}
return 'enabled';
@@ -479,7 +490,10 @@ export function registerDisable() {
if (!existingGroup) {
eventUtils.setGroup(true);
}
block!.setEnabled(!block!.isEnabled());
block!.setDisabledReason(
!block!.hasDisabledReason(MANUALLY_DISABLED),
MANUALLY_DISABLED,
);
eventUtils.setGroup(existingGroup);
},
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
@@ -554,6 +568,106 @@ export function registerHelp() {
ContextMenuRegistry.registry.register(helpOption);
}
/** Registers an option for deleting a workspace comment. */
export function registerCommentDelete() {
const deleteOption: RegistryItem = {
displayText: () => Msg['REMOVE_COMMENT'],
preconditionFn(scope: Scope) {
return scope.comment?.isDeletable() ? 'enabled' : 'hidden';
},
callback(scope: Scope) {
eventUtils.setGroup(true);
scope.comment?.dispose();
eventUtils.setGroup(false);
},
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
id: 'commentDelete',
weight: 6,
};
ContextMenuRegistry.registry.register(deleteOption);
}
/** Registers an option for duplicating a workspace comment. */
export function registerCommentDuplicate() {
const duplicateOption: RegistryItem = {
displayText: () => Msg['DUPLICATE_COMMENT'],
preconditionFn(scope: Scope) {
return scope.comment?.isMovable() ? 'enabled' : 'hidden';
},
callback(scope: Scope) {
if (!scope.comment) return;
const data = scope.comment.toCopyData();
if (!data) return;
clipboard.paste(data, scope.comment.workspace);
},
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
id: 'commentDuplicate',
weight: 1,
};
ContextMenuRegistry.registry.register(duplicateOption);
}
/** Registers an option for adding a workspace comment to the workspace. */
export function registerCommentCreate() {
const createOption: RegistryItem = {
displayText: () => Msg['ADD_COMMENT'],
preconditionFn: () => 'enabled',
callback: (scope: Scope, e: PointerEvent) => {
const workspace = scope.workspace;
if (!workspace) return;
eventUtils.setGroup(true);
const comment = new RenderedWorkspaceComment(workspace);
comment.setText(Msg['WORKSPACE_COMMENT_DEFAULT_TEXT']);
comment.moveTo(
pixelsToWorkspaceCoords(
new Coordinate(e.clientX, e.clientY),
workspace,
),
);
common.setSelected(comment);
eventUtils.setGroup(false);
},
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'commentCreate',
weight: 8,
};
ContextMenuRegistry.registry.register(createOption);
}
/**
* Converts pixel coordinates (relative to the window) to workspace coordinates.
*/
function pixelsToWorkspaceCoords(
pixelCoord: Coordinate,
workspace: WorkspaceSvg,
): Coordinate {
const injectionDiv = workspace.getInjectionDiv();
// Bounding rect coordinates are in client coordinates, meaning that they
// are in pixels relative to the upper left corner of the visible browser
// window. These coordinates change when you scroll the browser window.
const boundingRect = injectionDiv.getBoundingClientRect();
// The client coordinates offset by the injection div's upper left corner.
const clientOffsetPixels = new Coordinate(
pixelCoord.x - boundingRect.left,
pixelCoord.y - boundingRect.top,
);
// The offset in pixels between the main workspace's origin and the upper
// left corner of the injection div.
const mainOffsetPixels = workspace.getOriginOffsetInPixels();
// The position of the new comment in pixels relative to the origin of the
// main workspace.
const finalOffset = Coordinate.difference(
clientOffsetPixels,
mainOffsetPixels,
);
// The position of the new comment in main workspace coordinates.
finalOffset.scale(1 / workspace.scale);
return finalOffset;
}
/** Registers all block-scoped context menu items. */
function registerBlockOptions_() {
registerDuplicate();
@@ -565,6 +679,13 @@ function registerBlockOptions_() {
registerHelp();
}
/** Registers all workspace comment related menu items. */
export function registerCommentOptions() {
registerCommentDuplicate();
registerCommentDelete();
registerCommentCreate();
}
/**
* Registers all default context menu items. This should be called once per
* instance of ContextMenuRegistry.

View File

@@ -12,6 +12,7 @@
// Former goog.module ID: Blockly.ContextMenuRegistry
import type {BlockSvg} from './block_svg.js';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
@@ -119,6 +120,7 @@ export namespace ContextMenuRegistry {
export enum ScopeType {
BLOCK = 'block',
WORKSPACE = 'workspace',
COMMENT = 'comment',
}
/**
@@ -128,13 +130,20 @@ export namespace ContextMenuRegistry {
export interface Scope {
block?: BlockSvg;
workspace?: WorkspaceSvg;
comment?: RenderedWorkspaceComment;
}
/**
* A menu item as entered in the registry.
*/
export interface RegistryItem {
callback: (p1: Scope) => void;
/**
* @param scope Object that provides a reference to the thing that had its
* context menu opened.
* @param e The original event that triggered the context menu to open. Not
* the event that triggered the click on the option.
*/
callback: (scope: Scope, e: PointerEvent) => void;
scopeType: ScopeType;
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
preconditionFn: (p1: Scope) => string;
@@ -148,7 +157,13 @@ export namespace ContextMenuRegistry {
export interface ContextMenuOption {
text: string | HTMLElement;
enabled: boolean;
callback: (p1: Scope) => void;
/**
* @param scope Object that provides a reference to the thing that had its
* context menu opened.
* @param e The original event that triggered the context menu to open. Not
* the event that triggered the click on the option.
*/
callback: (scope: Scope, e: PointerEvent) => void;
scope: Scope;
weight: number;
}

View File

@@ -206,6 +206,9 @@ let content = `
.blocklyDragging {
cursor: grabbing;
cursor: -webkit-grabbing;
/* Drag surface disables events to not block the toolbox, so we have to
* reenable them here for the cursor values to work. */
pointer-events: auto;
}
/* Changes cursor on mouse down. Not effective in Firefox because of

View File

@@ -16,6 +16,7 @@ import {BlockSvg} from './block_svg.js';
import {DragTarget} from './drag_target.js';
import type {IDeleteArea} from './interfaces/i_delete_area.js';
import type {IDraggable} from './interfaces/i_draggable.js';
import {isDeletable} from './interfaces/i_deletable.js';
/**
* Abstract class for a component that can delete a block or bubble that is
@@ -51,17 +52,16 @@ export class DeleteArea extends DragTarget implements IDeleteArea {
* before onDragEnter/onDragOver/onDragExit.
*
* @param element The block or bubble currently being dragged.
* @param couldConnect Whether the element could could connect to another.
* @returns Whether the element provided would be deleted if dropped on this
* area.
*/
wouldDelete(element: IDraggable, couldConnect: boolean): boolean {
wouldDelete(element: IDraggable): boolean {
if (element instanceof BlockSvg) {
const block = element;
const couldDeleteBlock = !block.getParent() && block.isDeletable();
this.updateWouldDelete_(couldDeleteBlock && !couldConnect);
this.updateWouldDelete_(couldDeleteBlock);
} else {
this.updateWouldDelete_(element.isDeletable());
this.updateWouldDelete_(isDeletable(element) && element.isDeletable());
}
return this.wouldDelete_;
}

View File

@@ -39,8 +39,9 @@ export class DragTarget implements IDragTarget {
*
* @param _dragElement The block or bubble currently being dragged.
*/
onDragEnter(_dragElement: IDraggable) {}
// no-op
onDragEnter(_dragElement: IDraggable) {
// no-op
}
/**
* Handles when a cursor with a block or bubble is dragged over this drag
@@ -48,24 +49,27 @@ export class DragTarget implements IDragTarget {
*
* @param _dragElement The block or bubble currently being dragged.
*/
onDragOver(_dragElement: IDraggable) {}
// no-op
onDragOver(_dragElement: IDraggable) {
// no-op
}
/**
* Handles when a cursor with a block or bubble exits this drag target.
*
* @param _dragElement The block or bubble currently being dragged.
*/
onDragExit(_dragElement: IDraggable) {}
// no-op
onDragExit(_dragElement: IDraggable) {
// no-op
}
/**
* Handles when a block or bubble is dropped on this component.
* Should not handle delete here.
*
* @param _dragElement The block or bubble currently being dragged.
*/
onDrop(_dragElement: IDraggable) {}
// no-op
onDrop(_dragElement: IDraggable) {
// no-op
}
/**
* Returns the bounding rectangle of the drag target area in pixel units

12
core/dragging.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Dragger} from './dragging/dragger.js';
import {BlockDragStrategy} from './dragging/block_drag_strategy.js';
import {BubbleDragStrategy} from './dragging/bubble_drag_strategy.js';
import {CommentDragStrategy} from './dragging/comment_drag_strategy.js';
export {Dragger, BlockDragStrategy, BubbleDragStrategy, CommentDragStrategy};

View File

@@ -0,0 +1,453 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {WorkspaceSvg} from '../workspace_svg.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import {Coordinate} from '../utils.js';
import * as eventUtils from '../events/utils.js';
import {BlockSvg} from '../block_svg.js';
import {RenderedConnection} from '../rendered_connection.js';
import * as dom from '../utils/dom.js';
import * as blockAnimation from '../block_animations.js';
import {ConnectionType} from '../connection_type.js';
import * as bumpObjects from '../bump_objects.js';
import * as registry from '../registry.js';
import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js';
import {Connection} from '../connection.js';
import type {Block} from '../block.js';
import {config} from '../config.js';
import type {BlockMove} from '../events/events_block_move.js';
import {finishQueuedRenders} from '../render_management.js';
import * as layers from '../layers.js';
/** Represents a nearby valid connection. */
interface ConnectionCandidate {
/** A connection on the dragging stack that is compatible with neighbour. */
local: RenderedConnection;
/** A nearby connection that is compatible with local. */
neighbour: RenderedConnection;
/** The distance between the local connection and the neighbour connection. */
distance: number;
}
export class BlockDragStrategy implements IDragStrategy {
private workspace: WorkspaceSvg;
/** The parent block at the start of the drag. */
private startParentConn: RenderedConnection | null = null;
/**
* The child block at the start of the drag. Only gets set if
* `healStack` is true.
*/
private startChildConn: RenderedConnection | null = null;
private startLoc: Coordinate | null = null;
private connectionCandidate: ConnectionCandidate | null = null;
private connectionPreviewer: IConnectionPreviewer | null = null;
private dragging = false;
/**
* If this is a shadow block, the offset between this block and the parent
* block, to add to the drag location. In workspace units.
*/
private dragOffset = new Coordinate(0, 0);
constructor(private block: BlockSvg) {
this.workspace = block.workspace;
}
/** Returns true if the block is currently movable. False otherwise. */
isMovable(): boolean {
if (this.block.isShadow()) {
return this.block.getParent()?.isMovable() ?? false;
}
return (
this.block.isOwnMovable() &&
!this.block.isDeadOrDying() &&
!this.workspace.options.readOnly &&
// We never drag blocks in the flyout, only create new blocks that are
// dragged.
!this.block.isInFlyout
);
}
/**
* Handles any setup for starting the drag, including disconnecting the block
* from any parent blocks.
*/
startDrag(e?: PointerEvent): void {
if (this.block.isShadow()) {
this.startDraggingShadow(e);
return;
}
this.dragging = true;
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.fireDragStartEvent();
this.startLoc = this.block.getRelativeToSurfaceXY();
const previewerConstructor = registry.getClassFromOptions(
registry.Type.CONNECTION_PREVIEWER,
this.workspace.options,
);
this.connectionPreviewer = new previewerConstructor!(this.block);
// During a drag there may be a lot of rerenders, but not field changes.
// Turn the cache on so we don't do spurious remeasures during the drag.
dom.startTextWidthCache();
this.workspace.setResizesEnabled(false);
blockAnimation.disconnectUiStop();
const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey);
if (this.shouldDisconnect(healStack)) {
this.disconnectBlock(healStack);
}
this.block.setDragging(true);
this.workspace.getLayerManager()?.moveToDragLayer(this.block);
}
/** Starts a drag on a shadow, recording the drag offset. */
private startDraggingShadow(e?: PointerEvent) {
const parent = this.block.getParent();
if (!parent) {
throw new Error(
'Tried to drag a shadow block with no parent. ' +
'Shadow blocks should always have parents.',
);
}
this.dragOffset = Coordinate.difference(
parent.getRelativeToSurfaceXY(),
this.block.getRelativeToSurfaceXY(),
);
parent.startDrag(e);
}
/**
* Whether or not we should disconnect the block when a drag is started.
*
* @param healStack Whether or not to heal the stack after disconnecting.
* @returns True to disconnect the block, false otherwise.
*/
private shouldDisconnect(healStack: boolean): boolean {
return !!(
this.block.getParent() ||
(healStack &&
this.block.nextConnection &&
this.block.nextConnection.targetBlock())
);
}
/**
* Disconnects the block from any parents. If `healStack` is true and this is
* a stack block, we also disconnect from any next blocks and attempt to
* attach them to any parent.
*
* @param healStack Whether or not to heal the stack after disconnecting.
*/
private disconnectBlock(healStack: boolean) {
this.startParentConn =
this.block.outputConnection?.targetConnection ??
this.block.previousConnection?.targetConnection;
if (healStack) {
this.startChildConn = this.block.nextConnection?.targetConnection;
}
this.block.unplug(healStack);
blockAnimation.disconnectUiEffect(this.block);
}
/** Fire a UI event at the start of a block drag. */
private fireDragStartEvent() {
const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
this.block,
true,
this.block.getDescendants(false),
);
eventUtils.fire(event);
}
/** Fire a UI event at the end of a block drag. */
private fireDragEndEvent() {
const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
this.block,
false,
this.block.getDescendants(false),
);
eventUtils.fire(event);
}
/** Fire a move event at the end of a block drag. */
private fireMoveEvent() {
if (this.block.isDeadOrDying()) return;
const event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(
this.block,
) as BlockMove;
event.setReason(['drag']);
event.oldCoordinate = this.startLoc!;
event.recordNew();
eventUtils.fire(event);
}
/** Moves the block and updates any connection previews. */
drag(newLoc: Coordinate): void {
if (this.block.isShadow()) {
this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset));
return;
}
this.block.moveDuringDrag(newLoc);
this.updateConnectionPreview(
this.block,
Coordinate.difference(newLoc, this.startLoc!),
);
}
/**
* @param draggingBlock The block being dragged.
* @param delta How far the pointer has moved from the position
* at the start of the drag, in workspace units.
*/
private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) {
const currCandidate = this.connectionCandidate;
const newCandidate = this.getConnectionCandidate(draggingBlock, delta);
if (!newCandidate) {
this.connectionPreviewer!.hidePreview();
this.connectionCandidate = null;
return;
}
const candidate =
currCandidate &&
this.currCandidateIsBetter(currCandidate, delta, newCandidate)
? currCandidate
: newCandidate;
this.connectionCandidate = candidate;
const {local, neighbour} = candidate;
const localIsOutputOrPrevious =
local.type === ConnectionType.OUTPUT_VALUE ||
local.type === ConnectionType.PREVIOUS_STATEMENT;
const neighbourIsConnectedToRealBlock =
neighbour.isConnected() && !neighbour.targetBlock()!.isInsertionMarker();
if (
localIsOutputOrPrevious &&
neighbourIsConnectedToRealBlock &&
!this.orphanCanConnectAtEnd(
draggingBlock,
neighbour.targetBlock()!,
local.type,
)
) {
this.connectionPreviewer!.previewReplacement(
local,
neighbour,
neighbour.targetBlock()!,
);
return;
}
this.connectionPreviewer!.previewConnection(local, neighbour);
}
/**
* Returns true if the given orphan block can connect at the end of the
* top block's stack or row, false otherwise.
*/
private orphanCanConnectAtEnd(
topBlock: BlockSvg,
orphanBlock: BlockSvg,
localType: number,
): boolean {
const orphanConnection =
localType === ConnectionType.OUTPUT_VALUE
? orphanBlock.outputConnection
: orphanBlock.previousConnection;
return !!Connection.getConnectionForOrphanedConnection(
topBlock as Block,
orphanConnection as Connection,
);
}
/**
* Returns true if the current candidate is better than the new candidate.
*
* We slightly prefer the current candidate even if it is farther away.
*/
private currCandidateIsBetter(
currCandiate: ConnectionCandidate,
delta: Coordinate,
newCandidate: ConnectionCandidate,
): boolean {
const {local: currLocal, neighbour: currNeighbour} = currCandiate;
const localPos = new Coordinate(currLocal.x, currLocal.y);
const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y);
const currDistance = Coordinate.distance(
Coordinate.sum(localPos, delta),
neighbourPos,
);
return (
newCandidate.distance > currDistance - config.currentConnectionPreference
);
}
/**
* Returns the closest valid candidate connection, if one can be found.
*
* Valid neighbour connections are within the configured start radius, with a
* compatible type (input, output, etc) and connection check.
*/
private getConnectionCandidate(
draggingBlock: BlockSvg,
delta: Coordinate,
): ConnectionCandidate | null {
const localConns = this.getLocalConnections(draggingBlock);
let radius = this.connectionCandidate
? config.connectingSnapRadius
: config.snapRadius;
let candidate = null;
for (const conn of localConns) {
const {connection: neighbour, radius: rad} = conn.closest(radius, delta);
if (neighbour) {
candidate = {
local: conn,
neighbour: neighbour,
distance: rad,
};
radius = rad;
}
}
return candidate;
}
/**
* Returns all of the connections we might connect to blocks on the workspace.
*
* Includes any connections on the dragging block, and any last next
* connection on the stack (if one exists).
*/
private getLocalConnections(draggingBlock: BlockSvg): RenderedConnection[] {
const available = draggingBlock.getConnections_(false);
const lastOnStack = draggingBlock.lastConnectionInStack(true);
if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) {
available.push(lastOnStack);
}
return available;
}
/**
* Cleans up any state at the end of the drag. Applies any pending
* connections.
*/
endDrag(e?: PointerEvent): void {
if (this.block.isShadow()) {
this.block.getParent()?.endDrag(e);
return;
}
this.fireDragEndEvent();
this.fireMoveEvent();
dom.stopTextWidthCache();
blockAnimation.disconnectUiStop();
this.connectionPreviewer!.hidePreview();
if (!this.block.isDeadOrDying() && this.dragging) {
// These are expensive and don't need to be done if we're deleting, or
// if we've already stopped dragging because we moved back to the start.
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.block, layers.BLOCK);
this.block.setDragging(false);
}
if (this.connectionCandidate) {
// Applying connections also rerenders the relevant blocks.
this.applyConnections(this.connectionCandidate);
} else {
this.block.queueRender();
}
this.block.snapToGrid();
// Must dispose after connections are applied to not break the dynamic
// connections plugin. See #7859
this.connectionPreviewer!.dispose();
this.workspace.setResizesEnabled(true);
eventUtils.setGroup(false);
}
/** Connects the given candidate connections. */
private applyConnections(candidate: ConnectionCandidate) {
const {local, neighbour} = candidate;
local.connect(neighbour);
const inferiorConnection = local.isSuperior() ? neighbour : local;
const rootBlock = this.block.getRootBlock();
finishQueuedRenders().then(() => {
blockAnimation.connectionUiEffect(inferiorConnection.getSourceBlock());
// bringToFront is incredibly expensive. Delay until the next frame.
setTimeout(() => {
rootBlock.bringToFront();
}, 0);
});
}
/**
* Moves the block back to where it was at the beginning of the drag,
* including reconnecting connections.
*/
revertDrag(): void {
if (this.block.isShadow()) {
this.block.getParent()?.revertDrag();
return;
}
this.startChildConn?.connect(this.block.nextConnection);
if (this.startParentConn) {
switch (this.startParentConn.type) {
case ConnectionType.INPUT_VALUE:
this.startParentConn.connect(this.block.outputConnection);
break;
case ConnectionType.NEXT_STATEMENT:
this.startParentConn.connect(this.block.previousConnection);
}
} else {
this.block.moveTo(this.startLoc!, ['drag']);
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.block, layers.BLOCK);
// Blocks dragged directly from a flyout may need to be bumped into
// bounds.
bumpObjects.bumpIntoBounds(
this.workspace,
this.workspace.getMetricsManager().getScrollMetrics(true),
this.block,
);
}
this.startChildConn = null;
this.startParentConn = null;
this.connectionPreviewer!.hidePreview();
this.connectionCandidate = null;
this.block.setDragging(false);
this.dragging = false;
}
}

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {IDragStrategy} from '../interfaces/i_draggable.js';
import {Coordinate} from '../utils.js';
import * as eventUtils from '../events/utils.js';
import {IBubble, WorkspaceSvg} from '../blockly.js';
import * as layers from '../layers.js';
export class BubbleDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null;
constructor(
private bubble: IBubble,
private workspace: WorkspaceSvg,
) {}
isMovable(): boolean {
return true;
}
startDrag(): void {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.startLoc = this.bubble.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.bubble);
this.bubble.setDragging && this.bubble.setDragging(true);
}
drag(newLoc: Coordinate): void {
this.bubble.moveDuringDrag(newLoc);
}
endDrag(): void {
this.workspace.setResizesEnabled(true);
eventUtils.setGroup(false);
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.bubble, layers.BUBBLE);
this.bubble.setDragging(false);
}
revertDrag(): void {
if (this.startLoc) this.bubble.moveDuringDrag(this.startLoc);
}
}

View File

@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {IDragStrategy} from '../interfaces/i_draggable.js';
import {Coordinate} from '../utils.js';
import * as eventUtils from '../events/utils.js';
import * as layers from '../layers.js';
import {RenderedWorkspaceComment} from '../comments.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {CommentMove} from '../events/events_comment_move.js';
export class CommentDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null;
private workspace: WorkspaceSvg;
constructor(private comment: RenderedWorkspaceComment) {
this.workspace = comment.workspace;
}
isMovable(): boolean {
return this.comment.isOwnMovable() && !this.workspace.options.readOnly;
}
startDrag(): void {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.startLoc = this.comment.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.comment);
this.comment.setDragging(true);
}
drag(newLoc: Coordinate): void {
this.comment.moveDuringDrag(newLoc);
}
endDrag(): void {
this.fireMoveEvent();
this.workspace.setResizesEnabled(true);
eventUtils.setGroup(false);
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.comment, layers.BLOCK);
this.comment.setDragging(false);
}
private fireMoveEvent() {
if (this.comment.isDeadOrDying()) return;
const event = new (eventUtils.get(eventUtils.COMMENT_MOVE))(
this.comment,
) as CommentMove;
event.setReason(['drag']);
event.oldCoordinate_ = this.startLoc!;
event.recordNew();
eventUtils.fire(event);
}
revertDrag(): void {
if (this.startLoc) this.comment.moveDuringDrag(this.startLoc);
}
}

142
core/dragging/dragger.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {IDragTarget} from '../interfaces/i_drag_target.js';
import {IDeletable, isDeletable} from '../interfaces/i_deletable.js';
import {IDragger} from '../interfaces/i_dragger.js';
import {IDraggable} from '../interfaces/i_draggable.js';
import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {ComponentManager} from '../component_manager.js';
import {IDeleteArea} from '../interfaces/i_delete_area.js';
import * as registry from '../registry.js';
export class Dragger implements IDragger {
protected startLoc: Coordinate;
protected dragTarget: IDragTarget | null = null;
constructor(
protected draggable: IDraggable,
protected workspace: WorkspaceSvg,
) {
this.startLoc = draggable.getRelativeToSurfaceXY();
}
/** Handles any drag startup. */
onDragStart(e: PointerEvent) {
this.draggable.startDrag(e);
}
/**
* Handles calculating where the element should actually be moved to.
*
* @param totalDelta The total amount in pixel coordinates the mouse has moved
* since the start of the drag.
*/
onDrag(e: PointerEvent, totalDelta: Coordinate) {
this.moveDraggable(e, totalDelta);
// Must check `wouldDelete` before calling other hooks on drag targets
// since we have documented that we would do so.
if (isDeletable(this.draggable)) {
this.draggable.setDeleteStyle(
this.wouldDeleteDraggable(e, this.draggable),
);
}
this.updateDragTarget(e);
}
/** Updates the drag target under the pointer (if there is one). */
protected updateDragTarget(e: PointerEvent) {
const newDragTarget = this.workspace.getDragTarget(e);
if (this.dragTarget !== newDragTarget) {
this.dragTarget?.onDragExit(this.draggable);
newDragTarget?.onDragEnter(this.draggable);
}
newDragTarget?.onDragOver(this.draggable);
this.dragTarget = newDragTarget;
}
/**
* Calculates the correct workspace coordinate for the movable and tells
* the draggable to go to that location.
*/
private moveDraggable(e: PointerEvent, totalDelta: Coordinate) {
const delta = this.pixelsToWorkspaceUnits(totalDelta);
const newLoc = Coordinate.sum(this.startLoc, delta);
this.draggable.drag(newLoc, e);
}
/**
* Returns true if we would delete the draggable if it was dropped
* at the current location.
*/
protected wouldDeleteDraggable(
e: PointerEvent,
draggable: IDraggable & IDeletable,
) {
const dragTarget = this.workspace.getDragTarget(e);
if (!dragTarget) return false;
const componentManager = this.workspace.getComponentManager();
const isDeleteArea = componentManager.hasCapability(
dragTarget.id,
ComponentManager.Capability.DELETE_AREA,
);
if (!isDeleteArea) return false;
return (dragTarget as IDeleteArea).wouldDelete(draggable);
}
/** Handles any drag cleanup. */
onDragEnd(e: PointerEvent) {
const dragTarget = this.workspace.getDragTarget(e);
if (dragTarget) {
this.dragTarget?.onDrop(this.draggable);
}
if (this.shouldReturnToStart(e, this.draggable)) {
this.draggable.revertDrag();
}
this.draggable.endDrag(e);
if (
isDeletable(this.draggable) &&
this.wouldDeleteDraggable(e, this.draggable)
) {
this.draggable.dispose();
}
}
/**
* Returns true if we should return the draggable to its original location
* at the end of the drag.
*/
protected shouldReturnToStart(e: PointerEvent, draggable: IDraggable) {
const dragTarget = this.workspace.getDragTarget(e);
if (!dragTarget) return false;
return dragTarget.shouldPreventMove(draggable);
}
protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate {
const result = new Coordinate(
pixelCoord.x / this.workspace.scale,
pixelCoord.y / this.workspace.scale,
);
if (this.workspace.isMutator) {
// If we're in a mutator, its scale is always 1, purely because of some
// oddities in our rendering optimizations. The actual scale is the same
// as the scale on the parent workspace. Fix that for dragging.
const mainScale = this.workspace.options.parentWorkspace!.scale;
result.scale(1 / mainScale);
}
return result;
}
}
registry.register(registry.Type.BLOCK_DRAGGER, registry.DEFAULT, Dragger);

View File

@@ -24,6 +24,10 @@ import {CommentChange, CommentChangeJson} from './events_comment_change.js';
import {CommentCreate, CommentCreateJson} from './events_comment_create.js';
import {CommentDelete} from './events_comment_delete.js';
import {CommentMove, CommentMoveJson} from './events_comment_move.js';
import {
CommentCollapse,
CommentCollapseJson,
} from './events_comment_collapse.js';
import {MarkerMove, MarkerMoveJson} from './events_marker_move.js';
import {Selected, SelectedJson} from './events_selected.js';
import {ThemeChange, ThemeChangeJson} from './events_theme_change.js';
@@ -73,6 +77,8 @@ export {CommentCreateJson};
export {CommentDelete};
export {CommentMove};
export {CommentMoveJson};
export {CommentCollapse};
export {CommentCollapseJson};
export {FinishedLoading};
export {MarkerMove};
export {MarkerMoveJson};

View File

@@ -15,6 +15,7 @@ import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import {IconType} from '../icons/icon_types.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import {MANUALLY_DISABLED} from '../constants.js';
import * as registry from '../registry.js';
import * as utilsXml from '../utils/xml.js';
import {Workspace} from '../workspace.js';
@@ -44,6 +45,12 @@ export class BlockChange extends BlockBase {
/** The new value of the element. */
newValue: unknown;
/**
* If element is 'disabled', this is the language-neutral identifier of the
* reason why the block was or was not disabled.
*/
private disabledReason?: string;
/**
* @param opt_block The changed block. Undefined for a blank event.
* @param opt_element One of 'field', 'comment', 'disabled', etc.
@@ -86,6 +93,9 @@ export class BlockChange extends BlockBase {
json['name'] = this.name;
json['oldValue'] = this.oldValue;
json['newValue'] = this.newValue;
if (this.disabledReason) {
json['disabledReason'] = this.disabledReason;
}
return json;
}
@@ -112,9 +122,30 @@ export class BlockChange extends BlockBase {
newEvent.name = json['name'];
newEvent.oldValue = json['oldValue'];
newEvent.newValue = json['newValue'];
if (json['disabledReason'] !== undefined) {
newEvent.disabledReason = json['disabledReason'];
}
return newEvent;
}
/**
* Set the language-neutral identifier for the reason why the block was or was
* not disabled. This is only valid for events where element is 'disabled'.
* Defaults to 'MANUALLY_DISABLED'.
*
* @param disabledReason The identifier of the reason why the block was or was
* not disabled.
*/
setDisabledReason(disabledReason: string) {
if (this.element !== 'disabled') {
throw new Error(
'Cannot set the disabled reason for a BlockChange event if the ' +
'element is not "disabled".',
);
}
this.disabledReason = disabledReason;
}
/**
* Does this event record any change of state?
*
@@ -168,7 +199,10 @@ export class BlockChange extends BlockBase {
block.setCollapsed(!!value);
break;
case 'disabled':
block.setEnabled(!value);
block.setDisabledReason(
!!value,
this.disabledReason ?? MANUALLY_DISABLED,
);
break;
case 'inline':
block.setInputsInline(!!value);
@@ -219,6 +253,7 @@ export interface BlockChangeJson extends BlockBaseJson {
name?: string;
newValue: unknown;
oldValue: unknown;
disabledReason?: string;
}
registry.register(registry.Type.EVENT, eventUtils.CHANGE, BlockChange);

View File

@@ -11,10 +11,8 @@
*/
// Former goog.module ID: Blockly.Events.CommentBase
import * as utilsXml from '../utils/xml.js';
import type {WorkspaceComment} from '../workspace_comment.js';
import * as Xml from '../xml.js';
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import * as comments from '../serialization/workspace_comments.js';
import {
Abstract as AbstractEvent,
AbstractEventJson,
@@ -102,12 +100,10 @@ export class CommentBase extends AbstractEvent {
) {
const workspace = event.getEventWorkspace_();
if (create) {
const xmlElement = utilsXml.createElement('xml');
if (!event.xml) {
throw new Error('Ecountered a comment event without proper xml');
if (!event.json) {
throw new Error('Encountered a comment event without proper json');
}
xmlElement.appendChild(event.xml);
Xml.domToWorkspace(xmlElement, workspace);
comments.append(event.json, workspace);
} else {
if (!event.commentId) {
throw new Error(
@@ -119,8 +115,7 @@ export class CommentBase extends AbstractEvent {
if (comment) {
comment.dispose();
} else {
// Only complain about root-level block.
console.warn("Can't uncreate non-existent comment: " + event.commentId);
console.warn("Can't delete non-existent comment: " + event.commentId);
}
}
}

View File

@@ -12,7 +12,7 @@
// Former goog.module ID: Blockly.Events.CommentChange
import * as registry from '../registry.js';
import type {WorkspaceComment} from '../workspace_comment.js';
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import * as eventUtils from './utils.js';
@@ -124,13 +124,16 @@ export class CommentChange extends CommentBase {
'the constructor, or call fromJson',
);
}
const comment = workspace.getCommentById(this.commentId);
// TODO: Remove the cast when we fix the type of getCommentById.
const comment = workspace.getCommentById(
this.commentId,
) as unknown as WorkspaceComment;
if (!comment) {
console.warn("Can't change non-existent comment: " + this.commentId);
return;
}
const contents = forward ? this.newContents_ : this.oldContents_;
if (!contents) {
if (contents === undefined) {
if (forward) {
throw new Error(
'The new contents is undefined. Either pass a value to ' +
@@ -142,7 +145,7 @@ export class CommentChange extends CommentBase {
'the constructor, or call fromJson',
);
}
comment.setContent(contents);
comment.setText(contents);
}
}

View File

@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as registry from '../registry.js';
import {WorkspaceComment} from '../comments/workspace_comment.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import * as eventUtils from './utils.js';
import type {Workspace} from '../workspace.js';
export class CommentCollapse extends CommentBase {
override type = eventUtils.COMMENT_COLLAPSE;
constructor(
comment?: WorkspaceComment,
public newCollapsed?: boolean,
) {
super(comment);
if (!comment) {
return; // Blank event to be populated by fromJson.
}
}
/**
* Encode the event as JSON.
*
* @returns JSON representation.
*/
override toJson(): CommentCollapseJson {
const json = super.toJson() as CommentCollapseJson;
if (this.newCollapsed === undefined) {
throw new Error(
'The new collapse value undefined. Either call recordNew, or ' +
'call fromJson',
);
}
json['newCollapsed'] = this.newCollapsed;
return json;
}
/**
* Deserializes the JSON event.
*
* @param event The event to append new properties to. Should be a subclass
* of CommentCollapse, 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: CommentCollapseJson,
workspace: Workspace,
event?: any,
): CommentCollapse {
const newEvent = super.fromJson(
json,
workspace,
event ?? new CommentCollapse(),
) as CommentCollapse;
newEvent.newCollapsed = json.newCollapsed;
return newEvent;
}
/**
* Run a collapse event.
*
* @param forward True if run forward, false if run backward (undo).
*/
override run(forward: boolean) {
const workspace = this.getEventWorkspace_();
if (!this.commentId) {
throw new Error(
'The comment ID is undefined. Either pass a comment to ' +
'the constructor, or call fromJson',
);
}
// TODO: Remove cast when we update getCommentById.
const comment = workspace.getCommentById(
this.commentId,
) as unknown as WorkspaceComment;
if (!comment) {
console.warn(
"Can't collapse or uncollapse non-existent comment: " + this.commentId,
);
return;
}
comment.setCollapsed(forward ? !!this.newCollapsed : !this.newCollapsed);
}
}
export interface CommentCollapseJson extends CommentBaseJson {
newCollapsed: boolean;
}
registry.register(
registry.Type.EVENT,
eventUtils.COMMENT_COLLAPSE,
CommentCollapse,
);

View File

@@ -12,10 +12,10 @@
// Former goog.module ID: Blockly.Events.CommentCreate
import * as registry from '../registry.js';
import type {WorkspaceComment} from '../workspace_comment.js';
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import * as comments from '../serialization/workspace_comments.js';
import * as utilsXml from '../utils/xml.js';
import * as Xml from '../xml.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import * as eventUtils from './utils.js';
import type {Workspace} from '../workspace.js';
@@ -29,6 +29,9 @@ export class CommentCreate extends CommentBase {
/** The XML representation of the created workspace comment. */
xml?: Element | DocumentFragment;
/** The JSON representation of the created workspace comment. */
json?: comments.State;
/**
* @param opt_comment The created comment.
* Undefined for a blank event.
@@ -37,10 +40,11 @@ export class CommentCreate extends CommentBase {
super(opt_comment);
if (!opt_comment) {
return;
return; // Blank event to be populated by fromJson.
}
// Blank event to be populated by fromJson.
this.xml = opt_comment.toXmlWithXY();
this.xml = Xml.saveWorkspaceComment(opt_comment);
this.json = comments.save(opt_comment, {addCoordinates: true});
}
// TODO (#1266): "Full" and "minimal" serialization.
@@ -57,7 +61,14 @@ export class CommentCreate extends CommentBase {
'the constructor, or call fromJson',
);
}
if (!this.json) {
throw new Error(
'The comment JSON is undefined. Either pass a block to ' +
'the constructor, or call fromJson',
);
}
json['xml'] = Xml.domToText(this.xml);
json['json'] = this.json;
return json;
}
@@ -81,6 +92,7 @@ export class CommentCreate extends CommentBase {
event ?? new CommentCreate(),
) as CommentCreate;
newEvent.xml = utilsXml.textToDom(json['xml']);
newEvent.json = json['json'];
return newEvent;
}
@@ -96,6 +108,7 @@ export class CommentCreate extends CommentBase {
export interface CommentCreateJson extends CommentBaseJson {
xml: string;
json: object;
}
registry.register(

View File

@@ -12,8 +12,8 @@
// Former goog.module ID: Blockly.Events.CommentDelete
import * as registry from '../registry.js';
import type {WorkspaceComment} from '../workspace_comment.js';
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import * as comments from '../serialization/workspace_comments.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import * as eventUtils from './utils.js';
import * as utilsXml from '../utils/xml.js';
@@ -29,6 +29,9 @@ export class CommentDelete extends CommentBase {
/** The XML representation of the deleted workspace comment. */
xml?: Element;
/** The JSON representation of the created workspace comment. */
json?: comments.State;
/**
* @param opt_comment The deleted comment.
* Undefined for a blank event.
@@ -40,7 +43,8 @@ export class CommentDelete extends CommentBase {
return; // Blank event to be populated by fromJson.
}
this.xml = opt_comment.toXmlWithXY();
this.xml = Xml.saveWorkspaceComment(opt_comment);
this.json = comments.save(opt_comment, {addCoordinates: true});
}
/**
@@ -65,7 +69,14 @@ export class CommentDelete extends CommentBase {
'the constructor, or call fromJson',
);
}
if (!this.json) {
throw new Error(
'The comment JSON is undefined. Either pass a block to ' +
'the constructor, or call fromJson',
);
}
json['xml'] = Xml.domToText(this.xml);
json['json'] = this.json;
return json;
}
@@ -89,12 +100,14 @@ export class CommentDelete extends CommentBase {
event ?? new CommentDelete(),
) as CommentDelete;
newEvent.xml = utilsXml.textToDom(json['xml']);
newEvent.json = json['json'];
return newEvent;
}
}
export interface CommentDeleteJson extends CommentBaseJson {
xml: string;
json: object;
}
registry.register(

View File

@@ -13,7 +13,7 @@
import * as registry from '../registry.js';
import {Coordinate} from '../utils/coordinate.js';
import type {WorkspaceComment} from '../workspace_comment.js';
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import * as eventUtils from './utils.js';
@@ -35,6 +35,17 @@ export class CommentMove extends CommentBase {
/** The location of the comment after the move, in workspace coordinates. */
newCoordinate_?: Coordinate;
/**
* An explanation of what this move is for. Known values include:
* 'drag' -- A drag operation completed.
* 'snap' -- Comment got shifted to line up with the grid.
* 'inbounds' -- Block got pushed back into a non-scrolling workspace.
* 'create' -- Block created via deserialization.
* 'cleanup' -- Workspace aligned top-level blocks.
* Event merging may create multiple reasons: ['drag', 'inbounds', 'snap'].
*/
reason?: string[];
/**
* @param opt_comment The comment that is being moved. Undefined for a blank
* event.
@@ -70,6 +81,15 @@ export class CommentMove extends CommentBase {
this.newCoordinate_ = this.comment_.getRelativeToSurfaceXY();
}
/**
* Sets the reason for a move event.
*
* @param reason Why is this move happening? 'drag', 'bump', 'snap', ...
*/
setReason(reason: string[]) {
this.reason = reason;
}
/**
* Override the location before the move. Use this if you don't create the
* event until the end of the move, but you know the original location.
@@ -158,7 +178,10 @@ export class CommentMove extends CommentBase {
'the constructor, or call fromJson',
);
}
const comment = workspace.getCommentById(this.commentId);
// TODO: Remove cast when we update getCommentById.
const comment = workspace.getCommentById(
this.commentId,
) as unknown as WorkspaceComment;
if (!comment) {
console.warn("Can't move non-existent comment: " + this.commentId);
return;
@@ -172,9 +195,7 @@ export class CommentMove extends CommentBase {
'or call fromJson',
);
}
// TODO: Check if the comment is being dragged, and give up if so.
const current = comment.getRelativeToSurfaceXY();
comment.moveBy(target.x - current.x, target.y - current.y);
comment.moveTo(target);
}
}

View File

@@ -180,11 +180,20 @@ export const COMMENT_CHANGE = 'comment_change';
*/
export const COMMENT_MOVE = 'comment_move';
/** Type of event that moves a comment. */
export const COMMENT_COLLAPSE = 'comment_collapse';
/**
* Name of event that records a workspace load.
*/
export const FINISHED_LOADING = 'finished_loading';
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the block is not descended from a root block.
*/
const ORPHANED_BLOCK_DISABLED_REASON = 'ORPHANED_BLOCK';
/**
* Type of events that cause objects to be bumped back into the visible
* portion of the workspace.
@@ -513,10 +522,8 @@ export function get(
}
/**
* Enable/disable a block depending on whether it is properly connected.
* Set if a block is disabled depending on whether it is properly connected.
* Use this on applications where all blocks should be connected to a top block.
* Recommend setting the 'disable' option to 'false' in the config so that
* users don't try to re-enable disabled orphan blocks.
*
* @param event Custom data for event.
*/
@@ -539,17 +546,20 @@ export function disableOrphans(event: Abstract) {
try {
recordUndo = false;
const parent = block.getParent();
if (parent && parent.isEnabled()) {
if (
parent &&
!parent.hasDisabledReason(ORPHANED_BLOCK_DISABLED_REASON)
) {
const children = block.getDescendants(false);
for (let i = 0, child; (child = children[i]); i++) {
child.setEnabled(true);
child.setDisabledReason(false, ORPHANED_BLOCK_DISABLED_REASON);
}
} else if (
(block.outputConnection || block.previousConnection) &&
!eventWorkspace.isDragging()
) {
do {
block.setEnabled(false);
block.setDisabledReason(true, ORPHANED_BLOCK_DISABLED_REASON);
block = block.getNextBlock();
} while (block);
}

View File

@@ -314,6 +314,7 @@ export abstract class Field<T = any>
this.setTooltip(this.tooltip_);
this.bindEvents_();
this.initModel();
this.applyColour();
}
/**
@@ -1062,7 +1063,6 @@ export abstract class Field<T = any>
this.isDirty_ = true;
if (this.sourceBlock_ && this.sourceBlock_.rendered) {
(this.sourceBlock_ as BlockSvg).queueRender();
(this.sourceBlock_ as BlockSvg).bumpNeighbours();
}
}

View File

@@ -1,612 +0,0 @@
/**
* @license
* Copyright 2013 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Angle input field.
*
* @class
*/
// Former goog.module ID: Blockly.FieldAngle
import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as Css from './css.js';
import * as dropDownDiv from './dropdowndiv.js';
import * as eventUtils from './events/utils.js';
import {Field, UnattachedFieldError} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {
FieldInput,
FieldInputConfig,
FieldInputValidator,
} from './field_input.js';
import * as dom from './utils/dom.js';
import * as math from './utils/math.js';
import {Svg} from './utils/svg.js';
import * as userAgent from './utils/useragent.js';
import * as WidgetDiv from './widgetdiv.js';
/**
* Class for an editable angle field.
*/
export class FieldAngle extends FieldInput<number> {
/** Half the width of protractor image. */
static readonly HALF = 100 / 2;
/**
* Radius of protractor circle. Slightly smaller than protractor size since
* otherwise SVG crops off half the border at the edges.
*/
static readonly RADIUS: number = FieldAngle.HALF - 1;
/**
* Default property describing which direction makes an angle field's value
* increase. Angle increases clockwise (true) or counterclockwise (false).
*/
static readonly CLOCKWISE = false;
/**
* The default offset of 0 degrees (and all angles). Always offsets in the
* counterclockwise direction, regardless of the field's clockwise property.
* Usually either 0 (0 = right) or 90 (0 = up).
*/
static readonly OFFSET = 0;
/**
* The default maximum angle to allow before wrapping.
* Usually either 360 (for 0 to 359.9) or 180 (for -179.9 to 180).
*/
static readonly WRAP = 360;
/**
* The default amount to round angles to when using a mouse or keyboard nav
* input. Must be a positive integer to support keyboard navigation.
*/
static readonly ROUND = 15;
/**
* Whether the angle should increase as the angle picker is moved clockwise
* (true) or counterclockwise (false).
*/
private clockwise = FieldAngle.CLOCKWISE;
/**
* The offset of zero degrees (and all other angles).
*/
private offset = FieldAngle.OFFSET;
/**
* The maximum angle to allow before wrapping.
*/
private wrap = FieldAngle.WRAP;
/**
* The amount to round angles to when using a mouse or keyboard nav input.
*/
private round = FieldAngle.ROUND;
/**
* Array holding info needed to unbind events.
* Used for disposing.
* Ex: [[node, name, func], [node, name, func]].
*/
private boundEvents: browserEvents.Data[] = [];
/** Dynamic red line pointing at the value's angle. */
private line: SVGLineElement | null = null;
/** Dynamic pink area extending from 0 to the value's angle. */
private gauge: SVGPathElement | null = null;
/** The degree symbol for this field. */
protected symbol_: SVGTSpanElement | null = null;
/**
* @param value The initial value of the field. Should cast to a number.
* Defaults to 0. Also accepts Field.SKIP_SETUP if you wish to skip setup
* (only used by subclasses that want to handle configuration and setting
* the field value after their own constructors have run).
* @param validator A function that is called to validate changes to the
* field's value. Takes in a number & returns a validated number, or null
* to abort the change.
* @param config A map of options used to configure the field.
* See the [field creation documentation]{@link
* https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation}
* for a list of properties this parameter supports.
*/
constructor(
value?: string | number | typeof Field.SKIP_SETUP,
validator?: FieldAngleValidator,
config?: FieldAngleConfig,
) {
super(Field.SKIP_SETUP);
if (value === Field.SKIP_SETUP) return;
if (config) {
this.configure_(config);
}
this.setValue(value);
if (validator) {
this.setValidator(validator);
}
}
/**
* Configure the field based on the given map of options.
*
* @param config A map of options to configure the field based on.
*/
protected override configure_(config: FieldAngleConfig) {
super.configure_(config);
switch (config.mode) {
case Mode.COMPASS:
this.clockwise = true;
this.offset = 90;
break;
case Mode.PROTRACTOR:
// This is the default mode, so we could do nothing. But just to
// future-proof, we'll set it anyway.
this.clockwise = false;
this.offset = 0;
break;
}
// Allow individual settings to override the mode setting.
if (config.clockwise) this.clockwise = config.clockwise;
if (config.offset) this.offset = config.offset;
if (config.wrap) this.wrap = config.wrap;
if (config.round) this.round = config.round;
}
/**
* Create the block UI for this field.
*/
override initView() {
super.initView();
// Add the degree symbol to the left of the number,
// even in RTL (issue #2380).
this.symbol_ = dom.createSvgElement(Svg.TSPAN, {});
this.symbol_.appendChild(document.createTextNode('°'));
this.getTextElement().appendChild(this.symbol_);
}
/** Updates the angle when the field rerenders. */
protected override render_() {
super.render_();
this.updateGraph();
}
/**
* Create and show the angle field's editor.
*
* @param e Optional mouse event that triggered the field to open,
* or undefined if triggered programmatically.
*/
protected override showEditor_(e?: Event) {
// Mobile browsers have issues with in-line textareas (focus & keyboards).
const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD;
super.showEditor_(e, noFocus);
const editor = this.dropdownCreate();
dropDownDiv.getContentDiv().appendChild(editor);
if (this.sourceBlock_ instanceof BlockSvg) {
dropDownDiv.setColour(
this.sourceBlock_.style.colourPrimary,
this.sourceBlock_.style.colourTertiary,
);
}
dropDownDiv.showPositionedByField(this, this.dropdownDispose.bind(this));
this.updateGraph();
}
/**
* Creates the angle dropdown editor.
*
* @returns The newly created slider.
*/
private dropdownCreate(): SVGSVGElement {
const svg = dom.createSvgElement(Svg.SVG, {
'xmlns': dom.SVG_NS,
'xmlns:html': dom.HTML_NS,
'xmlns:xlink': dom.XLINK_NS,
'version': '1.1',
'height': FieldAngle.HALF * 2 + 'px',
'width': FieldAngle.HALF * 2 + 'px',
});
svg.style.touchAction = 'none';
const circle = dom.createSvgElement(
Svg.CIRCLE,
{
'cx': FieldAngle.HALF,
'cy': FieldAngle.HALF,
'r': FieldAngle.RADIUS,
'class': 'blocklyAngleCircle',
},
svg,
);
this.gauge = dom.createSvgElement(
Svg.PATH,
{'class': 'blocklyAngleGauge'},
svg,
);
this.line = dom.createSvgElement(
Svg.LINE,
{
'x1': FieldAngle.HALF,
'y1': FieldAngle.HALF,
'class': 'blocklyAngleLine',
},
svg,
);
// Draw markers around the edge.
for (let angle = 0; angle < 360; angle += 15) {
dom.createSvgElement(
Svg.LINE,
{
'x1': FieldAngle.HALF + FieldAngle.RADIUS,
'y1': FieldAngle.HALF,
'x2':
FieldAngle.HALF + FieldAngle.RADIUS - (angle % 45 === 0 ? 10 : 5),
'y2': FieldAngle.HALF,
'class': 'blocklyAngleMarks',
'transform':
'rotate(' +
angle +
',' +
FieldAngle.HALF +
',' +
FieldAngle.HALF +
')',
},
svg,
);
}
// The angle picker is different from other fields in that it updates on
// mousemove even if it's not in the middle of a drag. In future we may
// change this behaviour.
this.boundEvents.push(
browserEvents.conditionalBind(svg, 'click', this, this.hide),
);
// On touch devices, the picker's value is only updated with a drag. Add
// a click handler on the drag surface to update the value if the surface
// is clicked.
this.boundEvents.push(
browserEvents.conditionalBind(
circle,
'pointerdown',
this,
this.onMouseMove_,
true,
),
);
this.boundEvents.push(
browserEvents.conditionalBind(
circle,
'pointermove',
this,
this.onMouseMove_,
true,
),
);
return svg;
}
/** Disposes of events and DOM-references belonging to the angle editor. */
private dropdownDispose() {
for (const event of this.boundEvents) {
browserEvents.unbind(event);
}
this.boundEvents.length = 0;
this.gauge = null;
this.line = null;
}
/** Hide the editor. */
private hide() {
dropDownDiv.hideIfOwner(this);
WidgetDiv.hide();
}
/**
* Set the angle to match the mouse's position.
*
* @param e Mouse move event.
*/
protected onMouseMove_(e: PointerEvent) {
// Calculate angle.
const bBox = this.gauge!.ownerSVGElement!.getBoundingClientRect();
const dx = e.clientX - bBox.left - FieldAngle.HALF;
const dy = e.clientY - bBox.top - FieldAngle.HALF;
let angle = Math.atan(-dy / dx);
if (isNaN(angle)) {
// This shouldn't happen, but let's not let this error propagate further.
return;
}
angle = math.toDegrees(angle);
// 0: East, 90: North, 180: West, 270: South.
if (dx < 0) {
angle += 180;
} else if (dy > 0) {
angle += 360;
}
// Do offsetting.
if (this.clockwise) {
angle = this.offset + 360 - angle;
} else {
angle = 360 - (this.offset - angle);
}
this.displayMouseOrKeyboardValue(angle);
}
/**
* Handles and displays values that are input via mouse or arrow key input.
* These values need to be rounded and wrapped before being displayed so
* that the text input's value is appropriate.
*
* @param angle New angle.
*/
private displayMouseOrKeyboardValue(angle: number) {
if (this.round) {
angle = Math.round(angle / this.round) * this.round;
}
angle = this.wrapValue(angle);
if (angle !== this.value_) {
// Intermediate value changes from user input are not confirmed until the
// user closes the editor, and may be numerous. Inhibit reporting these as
// normal block change events, and instead report them as special
// intermediate changes that do not get recorded in undo history.
const oldValue = this.value_;
this.setEditorValue_(angle, /* fireChangeEvent= */ false);
if (
this.sourceBlock_ &&
eventUtils.isEnabled() &&
this.value_ !== oldValue
) {
eventUtils.fire(
new (eventUtils.get(eventUtils.BLOCK_FIELD_INTERMEDIATE_CHANGE))(
this.sourceBlock_,
this.name || null,
oldValue,
this.value_,
),
);
}
}
}
/** Redraw the graph with the current angle. */
private updateGraph() {
if (!this.gauge || !this.line) {
return;
}
// Always display the input (i.e. getText) even if it is invalid.
let angleDegrees = Number(this.getText()) + this.offset;
angleDegrees %= 360;
let angleRadians = math.toRadians(angleDegrees);
const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF];
let x2 = FieldAngle.HALF;
let y2 = FieldAngle.HALF;
if (!isNaN(angleRadians)) {
const clockwiseFlag = Number(this.clockwise);
const angle1 = math.toRadians(this.offset);
const x1 = Math.cos(angle1) * FieldAngle.RADIUS;
const y1 = Math.sin(angle1) * -FieldAngle.RADIUS;
if (clockwiseFlag) {
angleRadians = 2 * angle1 - angleRadians;
}
x2 += Math.cos(angleRadians) * FieldAngle.RADIUS;
y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS;
// Don't ask how the flag calculations work. They just do.
let largeFlag = Math.abs(
Math.floor((angleRadians - angle1) / Math.PI) % 2,
);
if (clockwiseFlag) {
largeFlag = 1 - largeFlag;
}
path.push(
' l ',
x1,
',',
y1,
' A ',
FieldAngle.RADIUS,
',',
FieldAngle.RADIUS,
' 0 ',
largeFlag,
' ',
clockwiseFlag,
' ',
x2,
',',
y2,
' z',
);
}
this.gauge.setAttribute('d', path.join(''));
this.line.setAttribute('x2', `${x2}`);
this.line.setAttribute('y2', `${y2}`);
}
/**
* Handle key down to the editor.
*
* @param e Keyboard event.
*/
protected override onHtmlInputKeyDown_(e: KeyboardEvent) {
super.onHtmlInputKeyDown_(e);
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
let multiplier = 0;
switch (e.key) {
case 'ArrowLeft':
// decrement (increment in RTL)
multiplier = block.RTL ? 1 : -1;
break;
case 'ArrowRight':
// increment (decrement in RTL)
multiplier = block.RTL ? -1 : 1;
break;
case 'ArrowDown':
// decrement
multiplier = -1;
break;
case 'ArrowUp':
// increment
multiplier = 1;
break;
}
if (multiplier) {
const value = this.getValue() as number;
this.displayMouseOrKeyboardValue(value + multiplier * this.round);
e.preventDefault();
e.stopPropagation();
}
}
/**
* Ensure that the input value is a valid angle.
*
* @param newValue The input value.
* @returns A valid angle, or null if invalid.
*/
protected override doClassValidation_(newValue?: any): number | null {
const value = Number(newValue);
if (isNaN(value) || !isFinite(value)) {
return null;
}
return this.wrapValue(value);
}
/**
* Wraps the value so that it is in the range (-360 + wrap, wrap).
*
* @param value The value to wrap.
* @returns The wrapped value.
*/
private wrapValue(value: number): number {
value %= 360;
if (value < 0) {
value += 360;
}
if (value > this.wrap) {
value -= 360;
}
return value;
}
/**
* Construct a FieldAngle from a JSON arg object.
*
* @param options A JSON object with options (angle).
* @returns The new field instance.
* @nocollapse
* @internal
*/
static override fromJson(options: FieldAngleFromJsonConfig): FieldAngle {
// `this` might be a subclass of FieldAngle if that class doesn't override
// the static fromJson method.
return new this(options.angle, undefined, options);
}
}
fieldRegistry.register('field_angle', FieldAngle);
FieldAngle.prototype.DEFAULT_VALUE = 0;
/**
* CSS for angle field.
*/
Css.register(`
.blocklyAngleCircle {
stroke: #444;
stroke-width: 1;
fill: #ddd;
fill-opacity: 0.8;
}
.blocklyAngleMarks {
stroke: #444;
stroke-width: 1;
}
.blocklyAngleGauge {
fill: #f88;
fill-opacity: 0.8;
pointer-events: none;
}
.blocklyAngleLine {
stroke: #f00;
stroke-width: 2;
stroke-linecap: round;
pointer-events: none;
}
`);
/**
* The two main modes of the angle field.
* Compass specifies:
* - clockwise: true
* - offset: 90
* - wrap: 0
* - round: 15
*
* Protractor specifies:
* - clockwise: false
* - offset: 0
* - wrap: 0
* - round: 15
*/
export enum Mode {
COMPASS = 'compass',
PROTRACTOR = 'protractor',
}
/**
* Extra configuration options for the angle field.
*/
export interface FieldAngleConfig extends FieldInputConfig {
mode?: Mode;
clockwise?: boolean;
offset?: number;
wrap?: number;
round?: number;
}
/**
* fromJson configuration options for the angle field.
*/
export interface FieldAngleFromJsonConfig extends FieldAngleConfig {
angle?: number;
}
/**
* A function that is called to validate changes to the field's value before
* they are set.
*
* @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}
* @param newValue The value to be validated.
* @returns One of three instructions for setting the new value: `T`, `null`,
* or `undefined`.
*
* - `T` to set this function's returned value instead of `newValue`.
*
* - `null` to invoke `doValueInvalid_` and not set a value.
*
* - `undefined` to set `newValue` as is.
*/
export type FieldAngleValidator = FieldInputValidator<number>;

View File

@@ -1,721 +0,0 @@
/**
* @license
* Copyright 2012 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Colour input field.
*
* @class
*/
// Former goog.module ID: Blockly.FieldColour
// Unused import preserved for side-effects. Remove if unneeded.
import './events/events_block_change.js';
import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as Css from './css.js';
import * as dom from './utils/dom.js';
import * as dropDownDiv from './dropdowndiv.js';
import {
Field,
FieldConfig,
FieldValidator,
UnattachedFieldError,
} from './field.js';
import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js';
import * as colour from './utils/colour.js';
import * as idGenerator from './utils/idgenerator.js';
import {Size} from './utils/size.js';
/**
* Class for a colour input field.
*/
export class FieldColour extends Field<string> {
/**
* An array of colour strings for the palette.
* Copied from goog.ui.ColorPicker.SIMPLE_GRID_COLORS
* All colour pickers use this unless overridden with setColours.
*/
// prettier-ignore
static COLOURS: string[] = [
// grays
'#ffffff', '#cccccc', '#c0c0c0', '#999999',
'#666666', '#333333', '#000000', // reds
'#ffcccc', '#ff6666', '#ff0000', '#cc0000',
'#990000', '#660000', '#330000', // oranges
'#ffcc99', '#ff9966', '#ff9900', '#ff6600',
'#cc6600', '#993300', '#663300', // yellows
'#ffff99', '#ffff66', '#ffcc66', '#ffcc33',
'#cc9933', '#996633', '#663333', // olives
'#ffffcc', '#ffff33', '#ffff00', '#ffcc00',
'#999900', '#666600', '#333300', // greens
'#99ff99', '#66ff99', '#33ff33', '#33cc00',
'#009900', '#006600', '#003300', // turquoises
'#99ffff', '#33ffff', '#66cccc', '#00cccc',
'#339999', '#336666', '#003333', // blues
'#ccffff', '#66ffff', '#33ccff', '#3366ff',
'#3333ff', '#000099', '#000066', // purples
'#ccccff', '#9999ff', '#6666cc', '#6633ff',
'#6600cc', '#333399', '#330099', // violets
'#ffccff', '#ff99ff', '#cc66cc', '#cc33cc',
'#993399', '#663366', '#330033',
];
/**
* An array of tooltip strings for the palette. If not the same length as
* COLOURS, the colour's hex code will be used for any missing titles.
* All colour pickers use this unless overridden with setColours.
*/
static TITLES: string[] = [];
/**
* Number of columns in the palette.
* All colour pickers use this unless overridden with setColumns.
*/
static COLUMNS = 7;
/** The field's colour picker element. */
private picker: HTMLElement | null = null;
/** Index of the currently highlighted element. */
private highlightedIndex: number | null = null;
/**
* Array holding info needed to unbind events.
* Used for disposing.
* Ex: [[node, name, func], [node, name, func]].
*/
private boundEvents: browserEvents.Data[] = [];
/**
* Serializable fields are saved by the serializer, non-serializable fields
* are not. Editable fields should also be serializable.
*/
override SERIALIZABLE = true;
/** Mouse cursor style when over the hotspot that initiates the editor. */
override CURSOR = 'default';
/**
* Used to tell if the field needs to be rendered the next time the block is
* rendered. Colour fields are statically sized, and only need to be
* rendered at initialization.
*/
protected override isDirty_ = false;
/** Array of colours used by this field. If null, use the global list. */
private colours: string[] | null = null;
/**
* Array of colour tooltips used by this field. If null, use the global
* list.
*/
private titles: string[] | null = null;
/**
* Number of colour columns used by this field. If 0, use the global
* setting. By default use the global constants for columns.
*/
private columns = 0;
/**
* @param value The initial value of the field. Should be in '#rrggbb'
* format. Defaults to the first value in the default colour array. Also
* accepts Field.SKIP_SETUP if you wish to skip setup (only used by
* subclasses that want to handle configuration and setting the field
* value after their own constructors have run).
* @param validator A function that is called to validate changes to the
* field's value. Takes in a colour string & returns a validated colour
* string ('#rrggbb' format), or null to abort the change.
* @param config A map of options used to configure the field.
* See the [field creation documentation]{@link
* https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour}
* for a list of properties this parameter supports.
*/
constructor(
value?: string | typeof Field.SKIP_SETUP,
validator?: FieldColourValidator,
config?: FieldColourConfig,
) {
super(Field.SKIP_SETUP);
if (value === Field.SKIP_SETUP) return;
if (config) {
this.configure_(config);
}
this.setValue(value);
if (validator) {
this.setValidator(validator);
}
}
/**
* Configure the field based on the given map of options.
*
* @param config A map of options to configure the field based on.
*/
protected override configure_(config: FieldColourConfig) {
super.configure_(config);
if (config.colourOptions) this.colours = config.colourOptions;
if (config.colourTitles) this.titles = config.colourTitles;
if (config.columns) this.columns = config.columns;
}
/**
* Create the block UI for this colour field.
*/
override initView() {
this.size_ = new Size(
this.getConstants()!.FIELD_COLOUR_DEFAULT_WIDTH,
this.getConstants()!.FIELD_COLOUR_DEFAULT_HEIGHT,
);
this.createBorderRect_();
this.getBorderRect().style['fillOpacity'] = '1';
this.getBorderRect().setAttribute('stroke', '#fff');
if (this.isFullBlockField()) {
this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();
}
}
protected override isFullBlockField(): boolean {
const block = this.getSourceBlock();
if (!block) throw new UnattachedFieldError();
const constants = this.getConstants();
return block.isSimpleReporter() && !!constants?.FIELD_COLOUR_FULL_BLOCK;
}
/**
* Updates text field to match the colour/style of the block.
*/
override applyColour() {
const block = this.getSourceBlock() as BlockSvg | null;
if (!block) throw new UnattachedFieldError();
if (!this.fieldGroup_) return;
const borderRect = this.borderRect_;
if (!borderRect) {
throw new Error('The border rect has not been initialized');
}
if (!this.isFullBlockField()) {
borderRect.style.display = 'block';
borderRect.style.fill = this.getValue() as string;
} else {
borderRect.style.display = 'none';
// In general, do *not* let fields control the color of blocks. Having the
// field control the color is unexpected, and could have performance
// impacts.
block.pathObject.svgPath.setAttribute('fill', this.getValue() as string);
block.pathObject.svgPath.setAttribute('stroke', '#fff');
}
}
/**
* Returns the height and width of the field.
*
* This should *in general* be the only place render_ gets called from.
*
* @returns Height and width.
*/
override getSize(): Size {
if (this.getConstants()?.FIELD_COLOUR_FULL_BLOCK) {
// In general, do *not* let fields control the color of blocks. Having the
// field control the color is unexpected, and could have performance
// impacts.
// Full block fields have more control of the block than they should
// (i.e. updating fill colour). Whenever we get the size, the field may
// no longer be a full-block field, so we need to rerender.
this.render_();
this.isDirty_ = false;
}
return super.getSize();
}
/**
* Updates the colour of the block to reflect whether this is a full
* block field or not.
*/
protected override render_() {
super.render_();
const block = this.getSourceBlock() as BlockSvg | null;
if (!block) throw new UnattachedFieldError();
// Calling applyColour updates the UI (full-block vs non-full-block) for the
// colour field, and the colour of the field/block.
block.applyColour();
}
/**
* Updates the size of the field based on whether it is a full block field
* or not.
*
* @param margin margin to use when positioning the field.
*/
protected updateSize_(margin?: number) {
const constants = this.getConstants();
let totalWidth;
let totalHeight;
if (this.isFullBlockField()) {
const xOffset = margin ?? 0;
totalWidth = xOffset * 2;
totalHeight = constants!.FIELD_TEXT_HEIGHT;
} else {
totalWidth = constants!.FIELD_COLOUR_DEFAULT_WIDTH;
totalHeight = constants!.FIELD_COLOUR_DEFAULT_HEIGHT;
}
this.size_.height = totalHeight;
this.size_.width = totalWidth;
this.positionBorderRect_();
}
/**
* Ensure that the input value is a valid colour.
*
* @param newValue The input value.
* @returns A valid colour, or null if invalid.
*/
protected override doClassValidation_(newValue?: any): string | null {
if (typeof newValue !== 'string') {
return null;
}
return colour.parse(newValue);
}
/**
* Get the text for this field. Used when the block is collapsed.
*
* @returns Text representing the value of this field.
*/
override getText(): string {
let colour = this.value_ as string;
// Try to use #rgb format if possible, rather than #rrggbb.
if (/^#(.)\1(.)\2(.)\3$/.test(colour)) {
colour = '#' + colour[1] + colour[3] + colour[5];
}
return colour;
}
/**
* Set a custom colour grid for this field.
*
* @param colours Array of colours for this block, or null to use default
* (FieldColour.COLOURS).
* @param titles Optional array of colour tooltips, or null to use default
* (FieldColour.TITLES).
* @returns Returns itself (for method chaining).
*/
setColours(colours: string[], titles?: string[]): FieldColour {
this.colours = colours;
if (titles) {
this.titles = titles;
}
return this;
}
/**
* Set a custom grid size for this field.
*
* @param columns Number of columns for this block, or 0 to use default
* (FieldColour.COLUMNS).
* @returns Returns itself (for method chaining).
*/
setColumns(columns: number): FieldColour {
this.columns = columns;
return this;
}
/** Create and show the colour field's editor. */
protected override showEditor_() {
this.dropdownCreate();
dropDownDiv.getContentDiv().appendChild(this.picker!);
dropDownDiv.showPositionedByField(this, this.dropdownDispose.bind(this));
// Focus so we can start receiving keyboard events.
this.picker!.focus({preventScroll: true});
}
/**
* Handle a click on a colour cell.
*
* @param e Mouse event.
*/
private onClick(e: PointerEvent) {
const cell = e.target as Element;
const colour = cell && cell.getAttribute('data-colour');
if (colour !== null) {
this.setValue(colour);
dropDownDiv.hideIfOwner(this);
}
}
/**
* Handle a key down event. Navigate around the grid with the
* arrow keys. Enter selects the highlighted colour.
*
* @param e Keyboard event.
*/
private onKeyDown(e: KeyboardEvent) {
let handled = true;
let highlighted: HTMLElement | null;
switch (e.key) {
case 'ArrowUp':
this.moveHighlightBy(0, -1);
break;
case 'ArrowDown':
this.moveHighlightBy(0, 1);
break;
case 'ArrowLeft':
this.moveHighlightBy(-1, 0);
break;
case 'ArrowRight':
this.moveHighlightBy(1, 0);
break;
case 'Enter':
// Select the highlighted colour.
highlighted = this.getHighlighted();
if (highlighted) {
const colour = highlighted.getAttribute('data-colour');
if (colour !== null) {
this.setValue(colour);
}
}
dropDownDiv.hideWithoutAnimation();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
}
}
/**
* Move the currently highlighted position by dx and dy.
*
* @param dx Change of x.
* @param dy Change of y.
*/
private moveHighlightBy(dx: number, dy: number) {
if (!this.highlightedIndex) {
return;
}
const colours = this.colours || FieldColour.COLOURS;
const columns = this.columns || FieldColour.COLUMNS;
// Get the current x and y coordinates.
let x = this.highlightedIndex % columns;
let y = Math.floor(this.highlightedIndex / columns);
// Add the offset.
x += dx;
y += dy;
if (dx < 0) {
// Move left one grid cell, even in RTL.
// Loop back to the end of the previous row if we have room.
if (x < 0 && y > 0) {
x = columns - 1;
y--;
} else if (x < 0) {
x = 0;
}
} else if (dx > 0) {
// Move right one grid cell, even in RTL.
// Loop to the start of the next row, if there's room.
if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) {
x = 0;
y++;
} else if (x > columns - 1) {
x--;
}
} else if (dy < 0) {
// Move up one grid cell, stop at the top.
if (y < 0) {
y = 0;
}
} else if (dy > 0) {
// Move down one grid cell, stop at the bottom.
if (y > Math.floor(colours.length / columns) - 1) {
y = Math.floor(colours.length / columns) - 1;
}
}
// Move the highlight to the new coordinates.
const cell = this.picker!.childNodes[y].childNodes[x] as Element;
const index = y * columns + x;
this.setHighlightedCell(cell, index);
}
/**
* Handle a mouse move event. Highlight the hovered colour.
*
* @param e Mouse event.
*/
private onMouseMove(e: PointerEvent) {
const cell = e.target as Element;
const index = cell && Number(cell.getAttribute('data-index'));
if (index !== null && index !== this.highlightedIndex) {
this.setHighlightedCell(cell, index);
}
}
/** Handle a mouse enter event. Focus the picker. */
private onMouseEnter() {
this.picker?.focus({preventScroll: true});
}
/**
* Handle a mouse leave event. Blur the picker and unhighlight
* the currently highlighted colour.
*/
private onMouseLeave() {
this.picker?.blur();
const highlighted = this.getHighlighted();
if (highlighted) {
dom.removeClass(highlighted, 'blocklyColourHighlighted');
}
}
/**
* Returns the currently highlighted item (if any).
*
* @returns Highlighted item (null if none).
*/
private getHighlighted(): HTMLElement | null {
if (!this.highlightedIndex) {
return null;
}
const columns = this.columns || FieldColour.COLUMNS;
const x = this.highlightedIndex % columns;
const y = Math.floor(this.highlightedIndex / columns);
const row = this.picker!.childNodes[y];
if (!row) {
return null;
}
return row.childNodes[x] as HTMLElement;
}
/**
* Update the currently highlighted cell.
*
* @param cell The new cell to highlight.
* @param index The index of the new cell.
*/
private setHighlightedCell(cell: Element, index: number) {
// Unhighlight the current item.
const highlighted = this.getHighlighted();
if (highlighted) {
dom.removeClass(highlighted, 'blocklyColourHighlighted');
}
// Highlight new item.
dom.addClass(cell, 'blocklyColourHighlighted');
// Set new highlighted index.
this.highlightedIndex = index;
// Update accessibility roles.
const cellId = cell.getAttribute('id');
if (cellId && this.picker) {
aria.setState(this.picker, aria.State.ACTIVEDESCENDANT, cellId);
}
}
/** Create a colour picker dropdown editor. */
private dropdownCreate() {
const columns = this.columns || FieldColour.COLUMNS;
const colours = this.colours || FieldColour.COLOURS;
const titles = this.titles || FieldColour.TITLES;
const selectedColour = this.getValue();
// Create the palette.
const table = document.createElement('table');
table.className = 'blocklyColourTable';
table.tabIndex = 0;
table.dir = 'ltr';
aria.setRole(table, aria.Role.GRID);
aria.setState(table, aria.State.EXPANDED, true);
aria.setState(
table,
aria.State.ROWCOUNT,
Math.floor(colours.length / columns),
);
aria.setState(table, aria.State.COLCOUNT, columns);
let row: Element;
for (let i = 0; i < colours.length; i++) {
if (i % columns === 0) {
row = document.createElement('tr');
aria.setRole(row, aria.Role.ROW);
table.appendChild(row);
}
const cell = document.createElement('td');
row!.appendChild(cell);
// This becomes the value, if clicked.
cell.setAttribute('data-colour', colours[i]);
cell.title = titles[i] || colours[i];
cell.id = idGenerator.getNextUniqueId();
cell.setAttribute('data-index', `${i}`);
aria.setRole(cell, aria.Role.GRIDCELL);
aria.setState(cell, aria.State.LABEL, colours[i]);
aria.setState(cell, aria.State.SELECTED, colours[i] === selectedColour);
cell.style.backgroundColor = colours[i];
if (colours[i] === selectedColour) {
cell.className = 'blocklyColourSelected';
this.highlightedIndex = i;
}
}
// Configure event handler on the table to listen for any event in a cell.
this.boundEvents.push(
browserEvents.conditionalBind(
table,
'pointerdown',
this,
this.onClick,
true,
),
);
this.boundEvents.push(
browserEvents.conditionalBind(
table,
'pointermove',
this,
this.onMouseMove,
true,
),
);
this.boundEvents.push(
browserEvents.conditionalBind(
table,
'pointerenter',
this,
this.onMouseEnter,
true,
),
);
this.boundEvents.push(
browserEvents.conditionalBind(
table,
'pointerleave',
this,
this.onMouseLeave,
true,
),
);
this.boundEvents.push(
browserEvents.conditionalBind(
table,
'keydown',
this,
this.onKeyDown,
false,
),
);
this.picker = table;
}
/** Disposes of events and DOM-references belonging to the colour editor. */
private dropdownDispose() {
for (const event of this.boundEvents) {
browserEvents.unbind(event);
}
this.boundEvents.length = 0;
this.picker = null;
this.highlightedIndex = null;
}
/**
* Construct a FieldColour from a JSON arg object.
*
* @param options A JSON object with options (colour).
* @returns The new field instance.
* @nocollapse
* @internal
*/
static override fromJson(options: FieldColourFromJsonConfig): FieldColour {
// `this` might be a subclass of FieldColour if that class doesn't override
// the static fromJson method.
return new this(options.colour, undefined, options);
}
}
/** The default value for this field. */
FieldColour.prototype.DEFAULT_VALUE = FieldColour.COLOURS[0];
fieldRegistry.register('field_colour', FieldColour);
/**
* CSS for colour picker.
*/
Css.register(`
.blocklyColourTable {
border-collapse: collapse;
display: block;
outline: none;
padding: 1px;
}
.blocklyColourTable>tr>td {
border: 0.5px solid #888;
box-sizing: border-box;
cursor: pointer;
display: inline-block;
height: 20px;
padding: 0;
width: 20px;
}
.blocklyColourTable>tr>td.blocklyColourHighlighted {
border-color: #eee;
box-shadow: 2px 2px 7px 2px rgba(0, 0, 0, 0.3);
position: relative;
}
.blocklyColourSelected, .blocklyColourSelected:hover {
border-color: #eee !important;
outline: 1px solid #333;
position: relative;
}
`);
/**
* Config options for the colour field.
*/
export interface FieldColourConfig extends FieldConfig {
colourOptions?: string[];
colourTitles?: string[];
columns?: number;
}
/**
* fromJson config options for the colour field.
*/
export interface FieldColourFromJsonConfig extends FieldColourConfig {
colour?: string;
}
/**
* A function that is called to validate changes to the field's value before
* they are set.
*
* @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}
* @param newValue The value to be validated.
* @returns One of three instructions for setting the new value: `T`, `null`,
* or `undefined`.
*
* - `T` to set this function's returned value instead of `newValue`.
*
* - `null` to invoke `doValueInvalid_` and not set a value.
*
* - `undefined` to set `newValue` as is.
*/
export type FieldColourValidator = FieldValidator<string>;

View File

@@ -284,7 +284,7 @@ export interface FieldImageConfig extends FieldConfig {
}
/**
* fromJson config options for the colour field.
* fromJson config options for the image field.
*/
export interface FieldImageFromJsonConfig extends FieldImageConfig {
src?: string;

View File

@@ -1,526 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Text Area field.
*
* @class
*/
// Former goog.module ID: Blockly.FieldMultilineInput
import * as Css from './css.js';
import {Field, UnattachedFieldError} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {
FieldTextInput,
FieldTextInputConfig,
FieldTextInputValidator,
} from './field_textinput.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Svg} from './utils/svg.js';
import * as userAgent from './utils/useragent.js';
import * as WidgetDiv from './widgetdiv.js';
/**
* Class for an editable text area field.
*/
export class FieldMultilineInput extends FieldTextInput {
/**
* The SVG group element that will contain a text element for each text row
* when initialized.
*/
textGroup: SVGGElement | null = null;
/**
* Defines the maximum number of lines of field.
* If exceeded, scrolling functionality is enabled.
*/
protected maxLines_ = Infinity;
/** Whether Y overflow is currently occurring. */
protected isOverflowedY_ = false;
/**
* @param value The initial content of the field. Should cast to a string.
* Defaults to an empty string if null or undefined. Also accepts
* Field.SKIP_SETUP if you wish to skip setup (only used by subclasses
* that want to handle configuration and setting the field value after
* their own constructors have run).
* @param validator An optional function that is called to validate any
* constraints on what the user entered. Takes the new text as an
* argument and returns either the accepted text, a replacement text, or
* null to abort the change.
* @param config A map of options used to configure the field.
* See the [field creation documentation]{@link
* https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation}
* for a list of properties this parameter supports.
*/
constructor(
value?: string | typeof Field.SKIP_SETUP,
validator?: FieldMultilineInputValidator,
config?: FieldMultilineInputConfig,
) {
super(Field.SKIP_SETUP);
if (value === Field.SKIP_SETUP) return;
if (config) {
this.configure_(config);
}
this.setValue(value);
if (validator) {
this.setValidator(validator);
}
}
/**
* Configure the field based on the given map of options.
*
* @param config A map of options to configure the field based on.
*/
protected override configure_(config: FieldMultilineInputConfig) {
super.configure_(config);
if (config.maxLines) this.setMaxLines(config.maxLines);
}
/**
* Serializes this field's value to XML. Should only be called by Blockly.Xml.
*
* @param fieldElement The element to populate with info about the field's
* state.
* @returns The element containing info about the field's state.
* @internal
*/
override toXml(fieldElement: Element): Element {
// Replace '\n' characters with HTML-escaped equivalent '&#10'. This is
// needed so the plain-text representation of the XML produced by
// `Blockly.Xml.domToText` will appear on a single line (this is a
// limitation of the plain-text format).
fieldElement.textContent = (this.getValue() as string).replace(
/\n/g,
'&#10;',
);
return fieldElement;
}
/**
* Sets the field's value based on the given XML element. Should only be
* called by Blockly.Xml.
*
* @param fieldElement The element containing info about the field's state.
* @internal
*/
override fromXml(fieldElement: Element) {
this.setValue(fieldElement.textContent!.replace(/&#10;/g, '\n'));
}
/**
* Saves this field's value.
* This function only exists for subclasses of FieldMultilineInput which
* predate the load/saveState API and only define to/fromXml.
*
* @returns The state of this field.
* @internal
*/
override saveState(): AnyDuringMigration {
const legacyState = this.saveLegacyState(FieldMultilineInput);
if (legacyState !== null) {
return legacyState;
}
return this.getValue();
}
/**
* Sets the field's value based on the given state.
* This function only exists for subclasses of FieldMultilineInput which
* predate the load/saveState API and only define to/fromXml.
*
* @param state The state of the variable to assign to this variable field.
* @internal
*/
override loadState(state: AnyDuringMigration) {
if (this.loadLegacyState(Field, state)) {
return;
}
this.setValue(state);
}
/**
* Create the block UI for this field.
*/
override initView() {
this.createBorderRect_();
this.textGroup = dom.createSvgElement(
Svg.G,
{
'class': 'blocklyEditableText',
},
this.fieldGroup_,
);
}
/**
* Get the text from this field as displayed on screen. May differ from
* getText due to ellipsis, and other formatting.
*
* @returns Currently displayed text.
*/
protected override getDisplayText_(): string {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
let textLines = this.getText();
if (!textLines) {
// Prevent the field from disappearing if empty.
return Field.NBSP;
}
const lines = textLines.split('\n');
textLines = '';
const displayLinesNumber = this.isOverflowedY_
? this.maxLines_
: lines.length;
for (let i = 0; i < displayLinesNumber; i++) {
let text = lines[i];
if (text.length > this.maxDisplayLength) {
// Truncate displayed string and add an ellipsis ('...').
text = text.substring(0, this.maxDisplayLength - 4) + '...';
} else if (this.isOverflowedY_ && i === displayLinesNumber - 1) {
text = text.substring(0, text.length - 3) + '...';
}
// Replace whitespace with non-breaking spaces so the text doesn't
// collapse.
text = text.replace(/\s/g, Field.NBSP);
textLines += text;
if (i !== displayLinesNumber - 1) {
textLines += '\n';
}
}
if (block.RTL) {
// The SVG is LTR, force value to be RTL.
textLines += '\u200F';
}
return textLines;
}
/**
* Called by setValue if the text input is valid. Updates the value of the
* field, and updates the text of the field if it is not currently being
* edited (i.e. handled by the htmlInput_). Is being redefined here to update
* overflow state of the field.
*
* @param newValue The value to be saved. The default validator guarantees
* that this is a string.
*/
protected override doValueUpdate_(newValue: string) {
super.doValueUpdate_(newValue);
if (this.value_ !== null) {
this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_;
}
}
/** Updates the text of the textElement. */
protected override render_() {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
// Remove all text group children.
let currentChild;
const textGroup = this.textGroup;
while ((currentChild = textGroup!.firstChild)) {
textGroup!.removeChild(currentChild);
}
// Add in text elements into the group.
const lines = this.getDisplayText_().split('\n');
let y = 0;
for (let i = 0; i < lines.length; i++) {
const lineHeight =
this.getConstants()!.FIELD_TEXT_HEIGHT +
this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING;
const span = dom.createSvgElement(
Svg.TEXT,
{
'class': 'blocklyText blocklyMultilineText',
'x': this.getConstants()!.FIELD_BORDER_RECT_X_PADDING,
'y': y + this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING,
'dy': this.getConstants()!.FIELD_TEXT_BASELINE,
},
textGroup,
);
span.appendChild(document.createTextNode(lines[i]));
y += lineHeight;
}
if (this.isBeingEdited_) {
const htmlInput = this.htmlInput_ as HTMLElement;
if (this.isOverflowedY_) {
dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
} else {
dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
}
}
this.updateSize_();
if (this.isBeingEdited_) {
if (block.RTL) {
// in RTL, we need to let the browser reflow before resizing
// in order to get the correct bounding box of the borderRect
// avoiding issue #2777.
setTimeout(this.resizeEditor_.bind(this), 0);
} else {
this.resizeEditor_();
}
const htmlInput = this.htmlInput_ as HTMLElement;
if (!this.isTextValid_) {
dom.addClass(htmlInput, 'blocklyInvalidInput');
aria.setState(htmlInput, aria.State.INVALID, true);
} else {
dom.removeClass(htmlInput, 'blocklyInvalidInput');
aria.setState(htmlInput, aria.State.INVALID, false);
}
}
}
/** Updates the size of the field based on the text. */
protected override updateSize_() {
const nodes = (this.textGroup as SVGElement).childNodes;
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE;
const fontWeight = this.getConstants()!.FIELD_TEXT_FONTWEIGHT;
const fontFamily = this.getConstants()!.FIELD_TEXT_FONTFAMILY;
let totalWidth = 0;
let totalHeight = 0;
for (let i = 0; i < nodes.length; i++) {
const tspan = nodes[i] as SVGTextElement;
const textWidth = dom.getFastTextWidth(
tspan,
fontSize,
fontWeight,
fontFamily,
);
if (textWidth > totalWidth) {
totalWidth = textWidth;
}
totalHeight +=
this.getConstants()!.FIELD_TEXT_HEIGHT +
(i > 0 ? this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING : 0);
}
if (this.isBeingEdited_) {
// The default width is based on the longest line in the display text,
// but when it's being edited, width should be calculated based on the
// absolute longest line, even if it would be truncated after editing.
// Otherwise we would get wrong editor width when there are more
// lines than this.maxLines_.
const actualEditorLines = String(this.value_).split('\n');
const dummyTextElement = dom.createSvgElement(Svg.TEXT, {
'class': 'blocklyText blocklyMultilineText',
});
for (let i = 0; i < actualEditorLines.length; i++) {
if (actualEditorLines[i].length > this.maxDisplayLength) {
actualEditorLines[i] = actualEditorLines[i].substring(
0,
this.maxDisplayLength,
);
}
dummyTextElement.textContent = actualEditorLines[i];
const lineWidth = dom.getFastTextWidth(
dummyTextElement,
fontSize,
fontWeight,
fontFamily,
);
if (lineWidth > totalWidth) {
totalWidth = lineWidth;
}
}
const scrollbarWidth =
this.htmlInput_!.offsetWidth - this.htmlInput_!.clientWidth;
totalWidth += scrollbarWidth;
}
if (this.borderRect_) {
totalHeight += this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING * 2;
totalWidth += this.getConstants()!.FIELD_BORDER_RECT_X_PADDING * 2;
this.borderRect_.setAttribute('width', `${totalWidth}`);
this.borderRect_.setAttribute('height', `${totalHeight}`);
}
this.size_.width = totalWidth;
this.size_.height = totalHeight;
this.positionBorderRect_();
}
/**
* Show the inline free-text editor on top of the text.
* Overrides the default behaviour to force rerender in order to
* correct block size, based on editor text.
*
* @param e Optional mouse event that triggered the field to open, or
* undefined if triggered programmatically.
* @param quietInput True if editor should be created without focus.
* Defaults to false.
*/
override showEditor_(e?: Event, quietInput?: boolean) {
super.showEditor_(e, quietInput);
this.forceRerender();
}
/**
* Create the text input editor widget.
*
* @returns The newly created text input editor.
*/
protected override widgetCreate_(): HTMLTextAreaElement {
const div = WidgetDiv.getDiv();
const scale = this.workspace_!.getScale();
const htmlInput = document.createElement('textarea');
htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput';
htmlInput.setAttribute('spellcheck', String(this.spellcheck_));
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt';
div!.style.fontSize = fontSize;
htmlInput.style.fontSize = fontSize;
const borderRadius = FieldTextInput.BORDERRADIUS * scale + 'px';
htmlInput.style.borderRadius = borderRadius;
const paddingX = this.getConstants()!.FIELD_BORDER_RECT_X_PADDING * scale;
const paddingY =
(this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING * scale) / 2;
htmlInput.style.padding =
paddingY + 'px ' + paddingX + 'px ' + paddingY + 'px ' + paddingX + 'px';
const lineHeight =
this.getConstants()!.FIELD_TEXT_HEIGHT +
this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING;
htmlInput.style.lineHeight = lineHeight * scale + 'px';
div!.appendChild(htmlInput);
htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
htmlInput.setAttribute('data-untyped-default-value', String(this.value_));
htmlInput.setAttribute('data-old-value', '');
if (userAgent.GECKO) {
// In FF, ensure the browser reflows before resizing to avoid issue #2777.
setTimeout(this.resizeEditor_.bind(this), 0);
} else {
this.resizeEditor_();
}
this.bindInputEvents_(htmlInput);
return htmlInput;
}
/**
* Sets the maxLines config for this field.
*
* @param maxLines Defines the maximum number of lines allowed, before
* scrolling functionality is enabled.
*/
setMaxLines(maxLines: number) {
if (
typeof maxLines === 'number' &&
maxLines > 0 &&
maxLines !== this.maxLines_
) {
this.maxLines_ = maxLines;
this.forceRerender();
}
}
/**
* Returns the maxLines config of this field.
*
* @returns The maxLines config value.
*/
getMaxLines(): number {
return this.maxLines_;
}
/**
* Handle key down to the editor. Override the text input definition of this
* so as to not close the editor when enter is typed in.
*
* @param e Keyboard event.
*/
protected override onHtmlInputKeyDown_(e: KeyboardEvent) {
if (e.key !== 'Enter') {
super.onHtmlInputKeyDown_(e);
}
}
/**
* Construct a FieldMultilineInput from a JSON arg object,
* dereferencing any string table references.
*
* @param options A JSON object with options (text, and spellcheck).
* @returns The new field instance.
* @nocollapse
* @internal
*/
static override fromJson(
options: FieldMultilineInputFromJsonConfig,
): FieldMultilineInput {
const text = parsing.replaceMessageReferences(options.text);
// `this` might be a subclass of FieldMultilineInput if that class doesn't
// override the static fromJson method.
return new this(text, undefined, options);
}
}
fieldRegistry.register('field_multilinetext', FieldMultilineInput);
/**
* CSS for multiline field.
*/
Css.register(`
.blocklyHtmlTextAreaInput {
font-family: monospace;
resize: none;
overflow: hidden;
height: 100%;
text-align: left;
}
.blocklyHtmlTextAreaInputOverflowedY {
overflow-y: scroll;
}
`);
/**
* Config options for the multiline input field.
*/
export interface FieldMultilineInputConfig extends FieldTextInputConfig {
maxLines?: number;
}
/**
* fromJson config options for the multiline input field.
*/
export interface FieldMultilineInputFromJsonConfig
extends FieldMultilineInputConfig {
text?: string;
}
/**
* A function that is called to validate changes to the field's value before
* they are set.
*
* @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}
* @param newValue The value to be validated.
* @returns One of three instructions for setting the new value: `T`, `null`,
* or `undefined`.
*
* - `T` to set this function's returned value instead of `newValue`.
*
* - `null` to invoke `doValueInvalid_` and not set a value.
*
* - `undefined` to set `newValue` as is.
*/
export type FieldMultilineInputValidator = FieldTextInputValidator;

View File

@@ -22,6 +22,7 @@ import * as eventUtils from './events/utils.js';
import {FlyoutButton} from './flyout_button.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import {MANUALLY_DISABLED} from './constants.js';
import type {Options} from './options.js';
import {ScrollbarPair} from './scrollbar_pair.js';
import * as blocks from './serialization/blocks.js';
@@ -43,6 +44,13 @@ enum FlyoutItemType {
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.
*/
@@ -828,6 +836,12 @@ export abstract class Flyout
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_,
@@ -1230,7 +1244,10 @@ export abstract class Flyout
common.getBlockTypeCounts(block),
);
while (block) {
block.setEnabled(enable);
block.setDisabledReason(
!enable,
WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON,
);
block = block.getNextBlock();
}
}
@@ -1275,8 +1292,8 @@ export abstract class Flyout
}
// Clone the block.
const json = blocks.save(oldBlock) as blocks.State;
// Normallly this resizes leading to weird jumps. Save it for terminateDrag.
const json = this.serializeBlock(oldBlock);
// Normally this resizes leading to weird jumps. Save it for terminateDrag.
targetWorkspace.setResizesEnabled(false);
const block = blocks.append(json, targetWorkspace) as BlockSvg;
@@ -1285,6 +1302,16 @@ export abstract class Flyout
return block;
}
/**
* Serialize a block to JSON.
*
* @param block The block to serialize.
* @returns A serialized representation of the block.
*/
protected serializeBlock(block: BlockSvg): blocks.State {
return blocks.save(block) as blocks.State;
}
/**
* Positions a block on the target workspace.
*

View File

@@ -16,7 +16,6 @@ import type {Block} from './block.js';
import * as common from './common.js';
import {Names, NameType} from './names.js';
import type {Workspace} from './workspace.js';
import {warn} from './utils/deprecation.js';
/**
* Deprecated, no-longer used type declaration for per-block-type generator
@@ -255,16 +254,7 @@ export class CodeGenerator {
// Look up block generator function in dictionary - but fall back
// to looking up on this if not found, for backwards compatibility.
let func = this.forBlock[block.type];
if (!func && (this as any)[block.type]) {
warn(
'block generator functions on CodeGenerator objects',
'10.0',
'11.0',
'the .forBlock[blockType] dictionary',
);
func = (this as any)[block.type];
}
const func = this.forBlock[block.type];
if (typeof func !== 'function') {
throw Error(
`${this.name_} generator does not know how to generate code ` +

View File

@@ -18,24 +18,23 @@ import './events/events_click.js';
import * as blockAnimations from './block_animations.js';
import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import {BubbleDragger} from './bubble_dragger.js';
import * as common from './common.js';
import {config} from './config.js';
import * as dropDownDiv from './dropdowndiv.js';
import * as eventUtils from './events/utils.js';
import type {Field} from './field.js';
import type {IBlockDragger} from './interfaces/i_block_dragger.js';
import type {IBubble} from './interfaces/i_bubble.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import * as internalConstants from './internal_constants.js';
import * as registry from './registry.js';
import * as Tooltip from './tooltip.js';
import * as Touch from './touch.js';
import {Coordinate} from './utils/coordinate.js';
import {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import {WorkspaceDragger} from './workspace_dragger.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import type {IIcon} from './interfaces/i_icon.js';
import {IDragger} from './interfaces/i_dragger.js';
import * as registry from './registry.js';
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {RenderedWorkspaceComment} from './comments.js';
/**
* Note: In this file "start" refers to pointerdown
@@ -84,6 +83,12 @@ export class Gesture {
*/
private startBlock: BlockSvg | null = null;
/**
* The comment that the gesture started on, or null if it did not start on a
* comment.
*/
private startComment: RenderedWorkspaceComment | null = null;
/**
* The block that this gesture targets. If the gesture started on a
* shadow block, this is the first non-shadow parent of the block. If the
@@ -113,11 +118,7 @@ export class Gesture {
*/
private boundEvents: browserEvents.Data[] = [];
/** The object tracking a bubble drag, or null if none is in progress. */
private bubbleDragger: BubbleDragger | null = null;
/** The object tracking a block drag, or null if none is in progress. */
private blockDragger: IBlockDragger | null = null;
private dragger: IDragger | null = null;
/**
* The object tracking a workspace or flyout workspace drag, or null if none
@@ -125,6 +126,9 @@ export class Gesture {
*/
private workspaceDragger: WorkspaceDragger | null = null;
/** Whether the gesture is dragging or not. */
private dragging: boolean = false;
/** The flyout a gesture started in, if any. */
private flyout: IFlyout | null = null;
@@ -136,7 +140,6 @@ export class Gesture {
/** Boolean used internally to break a cycle in disposal. */
protected isEnding_ = false;
private healStack: boolean;
/** The event that most recently updated this gesture. */
private mostRecentEvent: PointerEvent;
@@ -185,12 +188,6 @@ export class Gesture {
* (0, 0) is at this.mouseDownXY_.
*/
this.currentDragDeltaXY = new Coordinate(0, 0);
/**
* Boolean used to indicate whether or not to heal the stack after
* disconnecting a block.
*/
this.healStack = !internalConstants.DRAG_STACK;
}
/**
@@ -209,9 +206,6 @@ export class Gesture {
}
this.boundEvents.length = 0;
if (this.blockDragger) {
this.blockDragger.dispose();
}
if (this.workspaceDragger) {
this.workspaceDragger.dispose();
}
@@ -227,7 +221,7 @@ export class Gesture {
const changed = this.updateDragDelta(currentXY);
// Exceeded the drag radius for the first time.
if (changed) {
this.updateIsDragging();
this.updateIsDragging(e);
Touch.longStop();
}
this.mostRecentEvent = e;
@@ -294,54 +288,7 @@ export class Gesture {
// The start block is no longer relevant, because this is a drag.
this.startBlock = null;
this.targetBlock = this.flyout.createBlock(this.targetBlock);
this.targetBlock.select();
return true;
}
return false;
}
/**
* Update this gesture to record whether a bubble is being dragged.
* This function should be called on a pointermove event the first time
* the drag radius is exceeded. It should be called no more than once per
* gesture. If a bubble should be dragged this function creates the necessary
* BubbleDragger and starts the drag.
*
* @returns True if a bubble is being dragged.
*/
private updateIsDraggingBubble(): boolean {
if (!this.startBubble) {
return false;
}
this.startDraggingBubble();
return true;
}
/**
* Check whether to start a block drag. If a block should be dragged, either
* from the flyout or in the workspace, create the necessary BlockDragger and
* start the drag.
*
* This function should be called on a pointermove event the first time
* the drag radius is exceeded. It should be called no more than once per
* gesture. If a block should be dragged, either from the flyout or in the
* workspace, this function creates the necessary BlockDragger and starts the
* drag.
*
* @returns True if a block is being dragged.
*/
private updateIsDraggingBlock(): boolean {
if (!this.targetBlock) {
return false;
}
if (this.flyout) {
if (this.updateIsDraggingFromFlyout()) {
this.startDraggingBlock();
return true;
}
} else if (this.targetBlock.isMovable()) {
this.startDraggingBlock();
common.setSelected(this.targetBlock);
return true;
}
return false;
@@ -369,6 +316,7 @@ export class Gesture {
: this.startWorkspace_ && this.startWorkspace_.isDraggable();
if (!wsMovable) return;
this.dragging = true;
this.workspaceDragger = new WorkspaceDragger(this.startWorkspace_);
this.workspaceDragger.startDrag();
@@ -380,66 +328,43 @@ export class Gesture {
* the drag radius is exceeded. It should be called no more than once per
* gesture.
*/
private updateIsDragging() {
// Sanity check.
private updateIsDragging(e: PointerEvent) {
if (!this.startWorkspace_) {
throw new Error(
'Cannot update dragging because the start workspace is undefined',
);
}
if (this.calledUpdateIsDragging) {
throw Error('updateIsDragging_ should only be called once per gesture.');
}
this.calledUpdateIsDragging = true;
// First check if it was a bubble drag. Bubbles always sit on top of
// blocks.
if (this.updateIsDraggingBubble()) {
return;
// If we drag a block out of the flyout, it updates `common.getSelected`
// to return the new block.
if (this.flyout) this.updateIsDraggingFromFlyout();
const selected = common.getSelected();
if (selected && isDraggable(selected) && selected.isMovable()) {
this.dragging = true;
this.dragger = this.createDragger(selected, this.startWorkspace_);
this.dragger.onDragStart(e);
this.dragger.onDrag(e, this.currentDragDeltaXY);
} else {
this.updateIsDraggingWorkspace();
}
// Then check if it was a block drag.
if (this.updateIsDraggingBlock()) {
return;
}
// Then check if it's a workspace drag.
this.updateIsDraggingWorkspace();
}
/** Create a block dragger and start dragging the selected block. */
private startDraggingBlock() {
const BlockDraggerClass = registry.getClassFromOptions(
private createDragger(
draggable: IDraggable,
workspace: WorkspaceSvg,
): IDragger {
const DraggerClass = registry.getClassFromOptions(
registry.Type.BLOCK_DRAGGER,
this.creatorWorkspace.options,
true,
);
this.blockDragger = new BlockDraggerClass!(
this.targetBlock,
this.startWorkspace_,
);
this.blockDragger!.startDrag(this.currentDragDeltaXY, this.healStack);
this.blockDragger!.drag(this.mostRecentEvent, this.currentDragDeltaXY);
}
/** Create a bubble dragger and start dragging the selected bubble. */
private startDraggingBubble() {
if (!this.startBubble) {
throw new Error(
'Cannot update dragging the bubble because the start ' +
'bubble is undefined',
);
}
if (!this.startWorkspace_) {
throw new Error(
'Cannot update dragging the bubble because the start ' +
'workspace is undefined',
);
}
this.bubbleDragger = new BubbleDragger(
this.startBubble,
this.startWorkspace_,
);
this.bubbleDragger.startBubbleDrag();
this.bubbleDragger.dragBubble(
this.mostRecentEvent,
this.currentDragDeltaXY,
);
return new DraggerClass!(draggable, workspace);
}
/**
@@ -487,10 +412,6 @@ export class Gesture {
Tooltip.block();
if (this.targetBlock) {
this.targetBlock.select();
}
if (browserEvents.isRightButton(e)) {
this.handleRightClick(e);
return;
@@ -501,7 +422,6 @@ export class Gesture {
}
this.mouseDownXY = new Coordinate(e.clientX, e.clientY);
this.healStack = e.altKey || e.ctrlKey || e.metaKey;
this.bindMouseEvents(e);
@@ -581,13 +501,8 @@ export class Gesture {
this.updateFromEvent(e);
if (this.workspaceDragger) {
this.workspaceDragger.drag(this.currentDragDeltaXY);
} else if (this.blockDragger) {
this.blockDragger.drag(this.mostRecentEvent, this.currentDragDeltaXY);
} else if (this.bubbleDragger) {
this.bubbleDragger.dragBubble(
this.mostRecentEvent,
this.currentDragDeltaXY,
);
} else if (this.dragger) {
this.dragger.onDrag(this.mostRecentEvent, this.currentDragDeltaXY);
}
e.preventDefault();
e.stopPropagation();
@@ -623,15 +538,14 @@ export class Gesture {
// than clicks. Fields and icons have higher priority than blocks; blocks
// have higher priority than workspaces. The ordering within drags does
// not matter, because the three types of dragging are exclusive.
if (this.bubbleDragger) {
this.bubbleDragger.endBubbleDrag(e, this.currentDragDeltaXY);
} else if (this.blockDragger) {
this.blockDragger.endDrag(e, this.currentDragDeltaXY);
if (this.dragger) {
this.dragger.onDragEnd(e, this.currentDragDeltaXY);
} else if (this.workspaceDragger) {
this.workspaceDragger.endDrag(this.currentDragDeltaXY);
} else if (this.isBubbleClick()) {
// Bubbles are in front of all fields and blocks.
this.doBubbleClick();
// Do nothing, bubbles don't currently respond to clicks.
} else if (this.isCommentClick()) {
// Do nothing, comments don't currently respond to clicks.
} else if (this.isFieldClick()) {
this.doFieldClick();
} else if (this.isIconClick()) {
@@ -786,13 +700,8 @@ export class Gesture {
return;
}
Touch.longStop();
if (this.bubbleDragger) {
this.bubbleDragger.endBubbleDrag(
this.mostRecentEvent,
this.currentDragDeltaXY,
);
} else if (this.blockDragger) {
this.blockDragger.endDrag(this.mostRecentEvent, this.currentDragDeltaXY);
if (this.dragger) {
this.dragger.onDragEnd(this.mostRecentEvent, this.currentDragDeltaXY);
} else if (this.workspaceDragger) {
this.workspaceDragger.endDrag(this.currentDragDeltaXY);
}
@@ -812,6 +721,9 @@ export class Gesture {
this.targetBlock.showContextMenu(e);
} else if (this.startBubble) {
this.startBubble.showContextMenu(e);
} else if (this.startComment) {
this.startComment.workspace.hideChaff();
this.startComment.showContextMenu(e);
} else if (this.startWorkspace_ && !this.flyout) {
this.startWorkspace_.hideChaff();
this.startWorkspace_.showContextMenu(e);
@@ -840,6 +752,13 @@ export class Gesture {
}
this.setStartWorkspace(ws);
this.mostRecentEvent = e;
if (!this.startBlock && !this.startBubble && !this.startComment) {
// Selection determines what things start drags. So to drag the workspace,
// we need to deselect anything that was previously selected.
common.setSelected(null);
}
this.doStart(e);
}
@@ -908,19 +827,28 @@ export class Gesture {
this.mostRecentEvent = e;
}
/**
* Handle a pointerdown event on a workspace comment.
*
* @param e A pointerdown event.
* @param comment The comment the event hit.
* @internal
*/
handleCommentStart(e: PointerEvent, comment: RenderedWorkspaceComment) {
if (this.gestureHasStarted) {
throw Error(
'Tried to call gesture.handleCommentStart, ' +
'but the gesture had already been started.',
);
}
this.setStartComment(comment);
this.mostRecentEvent = e;
}
/* Begin functions defining what actions to take to execute clicks on each
* type of target. Any developer wanting to add behaviour on clicks should
* modify only this code. */
/** Execute a bubble click. */
private doBubbleClick() {
// TODO (#1673): Consistent handling of single clicks.
if (this.startBubble instanceof WorkspaceCommentSvg) {
this.startBubble.setFocus();
this.startBubble.select();
}
}
/** Execute a field click. */
private doFieldClick() {
if (!this.startField) {
@@ -963,7 +891,8 @@ export class Gesture {
eventUtils.setGroup(true);
}
const newBlock = this.flyout.createBlock(this.targetBlock);
newBlock.scheduleSnapAndBump();
newBlock.snapToGrid();
newBlock.bumpNeighbours();
}
} else {
if (!this.startWorkspace_) {
@@ -1063,6 +992,18 @@ export class Gesture {
}
}
/**
* Record the comment that a gesture started on
*
* @param comment The comment the gesture started on.
* @internal
*/
setStartComment(comment: RenderedWorkspaceComment) {
if (!this.startComment) {
this.startComment = comment;
}
}
/**
* Record the block that a gesture started on, and set the target block
* appropriately.
@@ -1074,6 +1015,7 @@ export class Gesture {
// If the gesture already went through a bubble, don't set the start block.
if (!this.startBlock && !this.startBubble) {
this.startBlock = block;
common.setSelected(this.startBlock);
if (block.isInFlyout && block !== block.getRootBlock()) {
this.setTargetBlock(block.getRootBlock());
} else {
@@ -1138,6 +1080,10 @@ export class Gesture {
return hasStartBubble && !this.hasExceededDragRadius;
}
private isCommentClick(): boolean {
return !!this.startComment && !this.hasExceededDragRadius;
}
/**
* Whether this gesture is a click on a block. This should only be called
* when ending a gesture (pointerup).
@@ -1196,6 +1142,11 @@ export class Gesture {
/* End helper functions defining types of clicks. */
/** Returns the current dragger if the gesture is a drag. */
getCurrentDragger(): WorkspaceDragger | IDragger | null {
return this.workspaceDragger ?? this.dragger ?? null;
}
/**
* Whether this gesture is a drag of either a workspace or block.
* This function is called externally to block actions that cannot be taken
@@ -1205,9 +1156,7 @@ export class Gesture {
* @internal
*/
isDragging(): boolean {
return (
!!this.workspaceDragger || !!this.blockDragger || !!this.bubbleDragger
);
return this.dragging;
}
/**
@@ -1222,31 +1171,6 @@ export class Gesture {
return this.gestureHasStarted;
}
/**
* Get a list of the insertion markers that currently exist. Block drags have
* 0, 1, or 2 insertion markers.
*
* @returns A possibly empty list of insertion marker blocks.
* @internal
*/
getInsertionMarkers(): BlockSvg[] {
if (this.blockDragger) {
return this.blockDragger.getInsertionMarkers();
}
return [];
}
/**
* Gets the current dragger if an item is being dragged. Null if nothing is
* being dragged.
*
* @returns The dragger that is currently in use or null if no drag is in
* progress.
*/
getCurrentDragger(): WorkspaceDragger | BubbleDragger | IBlockDragger | null {
return this.blockDragger ?? this.workspaceDragger ?? this.bubbleDragger;
}
/**
* Is a drag or other gesture currently in progress on any workspace?
*

View File

@@ -13,6 +13,7 @@
// Former goog.module ID: Blockly.Grid
import * as dom from './utils/dom.js';
import {Coordinate} from './utils/coordinate.js';
import {Svg} from './utils/svg.js';
import {GridOptions} from './options.js';
@@ -184,6 +185,25 @@ export class Grid {
this.pattern.setAttribute('y', `${y}`);
}
/**
* Given a coordinate, return the nearest coordinate aligned to the grid.
*
* @param xy A workspace coordinate.
* @returns Workspace coordinate of nearest grid point.
* If there's no change, return the same coordinate object.
*/
alignXY(xy: Coordinate): Coordinate {
const spacing = this.getSpacing();
const half = spacing / 2;
const x = Math.round(Math.round((xy.x - half) / spacing) * spacing + half);
const y = Math.round(Math.round((xy.y - half) / spacing) * spacing + half);
if (x === xy.x && y === xy.y) {
// No change.
return xy;
}
return new Coordinate(x, y);
}
/**
* Create the DOM for the grid described by options.
*

View File

@@ -22,6 +22,7 @@ import {Svg} from '../utils/svg.js';
import {TextBubble} from '../bubbles/text_bubble.js';
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import * as renderManagement from '../render_management.js';
/** The size of the comment icon in workspace-scale units. */
const SIZE = 17;
@@ -138,12 +139,12 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
* Updates the state of the bubble (editable / noneditable) to reflect the
* state of the bubble if the bubble is currently shown.
*/
override updateEditable(): void {
override async updateEditable(): Promise<void> {
super.updateEditable();
if (this.bubbleIsVisible()) {
// Close and reopen the bubble to display the correct UI.
this.setBubbleVisible(false);
this.setBubbleVisible(true);
await this.setBubbleVisible(false);
await this.setBubbleVisible(true);
}
}
@@ -214,8 +215,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
state['height'] ?? DEFAULT_BUBBLE_HEIGHT,
);
this.bubbleVisiblity = state['pinned'] ?? false;
// Give the block a chance to be positioned and rendered before showing.
setTimeout(() => this.setBubbleVisible(this.bubbleVisiblity), 1);
this.setBubbleVisible(this.bubbleVisiblity);
}
override onClick(): void {
@@ -263,13 +263,18 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
return this.bubbleVisiblity;
}
setBubbleVisible(visible: boolean): void {
if (visible && (this.textBubble || this.textInputBubble)) return;
if (!visible && !(this.textBubble || this.textInputBubble)) return;
async setBubbleVisible(visible: boolean): Promise<void> {
if (this.bubbleVisiblity === visible) return;
this.bubbleVisiblity = visible;
if (!this.sourceBlock.rendered || this.sourceBlock.isInFlyout) return;
await renderManagement.finishQueuedRenders();
if (
!this.sourceBlock.rendered ||
this.sourceBlock.isInFlyout ||
this.sourceBlock.isInsertionMarker()
)
return;
if (visible) {
if (this.sourceBlock.isEditable()) {
@@ -347,10 +352,18 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}
}
/** The save state format for a comment icon. */
export interface CommentState {
/** The text of the comment. */
text?: string;
/** True if the comment is open, false otherwise. */
pinned?: boolean;
/** The height of the comment bubble. */
height?: number;
/** The width of the comment bubble. */
width?: number;
}

View File

@@ -14,7 +14,6 @@ import * as dom from '../utils/dom.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import type {IconType} from './icon_types.js';
import * as deprecation from '../utils/deprecation.js';
import * as tooltip from '../tooltip.js';
/**
@@ -145,14 +144,4 @@ export abstract class Icon implements IIcon {
isClickableInFlyout(autoClosingFlyout: boolean): boolean {
return true;
}
/**
* Sets the visibility of the icon's bubble if one exists.
*
* @deprecated Use `setBubbleVisible` instead. To be removed in v11.
*/
setVisible(visibility: boolean): void {
deprecation.warn('setVisible', 'v10', 'v11', 'setBubbleVisible');
if (hasBubble(this)) this.setBubbleVisible(visibility);
}
}

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {ICommentIcon} from '../interfaces/i_comment_icon.js';
import {IIcon} from '../interfaces/i_icon.js';
import {CommentIcon} from './comment_icon.js';
import {MutatorIcon} from './mutator_icon.js';
import {WarningIcon} from './warning_icon.js';
@@ -28,5 +28,5 @@ export class IconType<_T extends IIcon> {
static MUTATOR = new IconType<MutatorIcon>('mutator');
static WARNING = new IconType<WarningIcon>('warning');
static COMMENT = new IconType<CommentIcon>('comment');
static COMMENT = new IconType<ICommentIcon>('comment');
}

View File

@@ -7,11 +7,9 @@
// Former goog.module ID: Blockly.Mutator
import type {Abstract} from '../events/events_abstract.js';
import type {Block} from '../block.js';
import {BlockChange} from '../events/events_block_change.js';
import type {BlocklyOptions} from '../blockly_options.js';
import type {BlockSvg} from '../block_svg.js';
import type {Connection} from '../connection.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as eventUtils from '../events/utils.js';
@@ -22,8 +20,8 @@ import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import * as deprecation from '../utils/deprecation.js';
import {IconType} from './icon_types.js';
import * as renderManagement from '../render_management.js';
/** The size of the mutator icon in workspace-scale units. */
const SIZE = 17;
@@ -165,9 +163,11 @@ export class MutatorIcon extends Icon implements IHasBubble {
return !!this.miniWorkspaceBubble;
}
setBubbleVisible(visible: boolean): void {
async setBubbleVisible(visible: boolean): Promise<void> {
if (this.bubbleIsVisible() === visible) return;
await renderManagement.finishQueuedRenders();
if (visible) {
this.miniWorkspaceBubble = new MiniWorkspaceBubble(
this.getMiniWorkspaceConfig(),
@@ -351,40 +351,4 @@ export class MutatorIcon extends Icon implements IHasBubble {
getWorkspace(): WorkspaceSvg | undefined {
return this.miniWorkspaceBubble?.getWorkspace();
}
/**
* Reconnects the given connection to the mutated input on the given block.
*
* @deprecated Use connection.reconnect instead. To be removed in v11.
*/
static reconnect(
connectionChild: Connection | null,
block: Block,
inputName: string,
): boolean {
deprecation.warn(
'MutatorIcon.reconnect',
'v10',
'v11',
'connection.reconnect',
);
if (!connectionChild) return false;
return connectionChild.reconnect(block, inputName);
}
/**
* Returns the parent workspace of a workspace that is inside a mini workspace
* bubble, taking into account whether the workspace is a flyout.
*
* @deprecated Use workspace.getRootWorkspace. To be removed in v11.
*/
static findParentWs(workspace: WorkspaceSvg): WorkspaceSvg | null {
deprecation.warn(
'MutatorIcon.findParentWs',
'v10',
'v11',
'workspace.getRootWorkspace',
);
return workspace.getRootWorkspace();
}
}

View File

@@ -17,6 +17,7 @@ import {Size} from '../utils.js';
import {Svg} from '../utils/svg.js';
import {TextBubble} from '../bubbles/text_bubble.js';
import {IconType} from './icon_types.js';
import * as renderManagement from '../render_management.js';
/** The size of the warning icon in workspace-scale units. */
const SIZE = 17;
@@ -168,9 +169,11 @@ export class WarningIcon extends Icon implements IHasBubble {
return !!this.textBubble;
}
setBubbleVisible(visible: boolean): void {
async setBubbleVisible(visible: boolean): Promise<void> {
if (this.bubbleIsVisible() === visible) return;
await renderManagement.finishQueuedRenders();
if (visible) {
this.textBubble = new TextBubble(
this.getText(),

View File

@@ -53,7 +53,10 @@ export function inject(
}
const options = new Options(opt_options || ({} as BlocklyOptions));
const subContainer = document.createElement('div');
subContainer.className = 'injectionDiv';
dom.addClass(subContainer, 'injectionDiv');
if (opt_options?.rtl) {
dom.addClass(subContainer, 'blocklyRTL');
}
subContainer.tabIndex = 0;
aria.setState(subContainer, aria.State.LABEL, Msg['WORKSPACE_ARIA_LABEL']);

View File

@@ -22,6 +22,7 @@ import type {Field} from '../field.js';
import * as fieldRegistry from '../field_registry.js';
import type {RenderedConnection} from '../rendered_connection.js';
import {inputTypes} from './input_types.js';
import {Align} from './align.js';
/** Class for an input with optional fields. */
export class Input {
@@ -102,10 +103,7 @@ export class Input {
}
field.setSourceBlock(this.sourceBlock);
if (this.sourceBlock.rendered) {
field.init();
field.applyColour();
}
if (this.sourceBlock.initialized) this.initField(field);
field.name = opt_name;
field.setVisible(this.isVisible());
@@ -123,8 +121,6 @@ export class Input {
if (this.sourceBlock.rendered) {
(this.sourceBlock as BlockSvg).queueRender();
// Adding a field will cause the block to change shape.
this.sourceBlock.bumpNeighbours();
}
return index;
}
@@ -145,8 +141,6 @@ export class Input {
this.fieldRow.splice(i, 1);
if (this.sourceBlock.rendered) {
(this.sourceBlock as BlockSvg).queueRender();
// Removing a field will cause the block to change shape.
this.sourceBlock.bumpNeighbours();
}
return true;
}
@@ -273,11 +267,28 @@ export class Input {
/** Initialize the fields on this input. */
init() {
if (!this.sourceBlock.workspace.rendered) {
return; // Headless blocks don't need fields initialized.
for (const field of this.fieldRow) {
field.init();
}
for (let i = 0; i < this.fieldRow.length; i++) {
this.fieldRow[i].init();
}
/**
* Initializes the fields on this input for a headless block.
*
* @internal
*/
public initModel() {
for (const field of this.fieldRow) {
field.initModel();
}
}
/** Initializes the given field. */
private initField(field: Field) {
if (this.sourceBlock.rendered) {
field.init();
} else {
field.initModel();
}
}
@@ -305,25 +316,3 @@ export class Input {
return this.sourceBlock.makeConnection_(type);
}
}
export namespace Input {
// TODO(v11): When this is removed in v11, also re-enable errors on access
// of deprecated things (in build_tasks.js).
/**
* Enum for alignment of inputs.
*
* @deprecated Use Blockly.inputs.Align. To be removed in v11.
*/
export enum Align {
LEFT = -1,
CENTRE = 0,
RIGHT = 1,
}
}
/** @deprecated Use Blockly.inputs.Align. To be removed in v11. */
/** @suppress {deprecated} */
export type Align = Input.Align;
/** @deprecated Use Blockly.inputs.Align. To be removed in v11. */
/** @suppress {deprecated} */
export const Align = Input.Align;

View File

@@ -17,7 +17,7 @@ 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 constants from './constants.js';
import * as blocks from './serialization/blocks.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';
@@ -42,16 +42,6 @@ interface CandidateConnection {
radius: number;
}
/**
* An error message to throw if the block created by createMarkerBlock_ is
* missing any components.
*/
const DUPLICATE_BLOCK_ERROR =
'The insertion marker ' +
'manager tried to create a marker but the result is missing %1. If ' +
'you are using a mutator, make sure your domToMutation method is ' +
'properly defined.';
/**
* Class that controls updates to connections during drags. It is primarily
* responsible for finding the closest eligible connection and highlighting or
@@ -188,18 +178,16 @@ export class InsertionMarkerManager {
eventUtils.enable();
const {local, closest} = this.activeCandidate;
local.connect(closest);
if (this.topBlock.rendered) {
const inferiorConnection = local.isSuperior() ? closest : local;
const rootBlock = this.topBlock.getRootBlock();
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);
});
}
finishQueuedRenders().then(() => {
blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock());
// bringToFront is incredibly expensive. Delay until the next frame.
setTimeout(() => {
rootBlock.bringToFront();
}, 0);
});
}
/**
@@ -233,53 +221,30 @@ export class InsertionMarkerManager {
* @returns The insertion marker that represents the given block.
*/
private createMarkerBlock(sourceBlock: BlockSvg): BlockSvg {
const imType = sourceBlock.type;
eventUtils.disable();
let result: BlockSvg;
try {
result = this.workspace.newBlock(imType);
result.setInsertionMarker(true);
if (sourceBlock.saveExtraState) {
const state = sourceBlock.saveExtraState(true);
if (state && result.loadExtraState) {
result.loadExtraState(state);
}
} else if (sourceBlock.mutationToDom) {
const oldMutationDom = sourceBlock.mutationToDom();
if (oldMutationDom && result.domToMutation) {
result.domToMutation(oldMutationDom);
}
}
// Copy field values from the other block. These values may impact the
// rendered size of the insertion marker. Note that we do not care about
// child blocks here.
for (let i = 0; i < sourceBlock.inputList.length; i++) {
const sourceInput = sourceBlock.inputList[i];
if (sourceInput.name === constants.COLLAPSED_INPUT_NAME) {
continue; // Ignore the collapsed input.
}
const resultInput = result.inputList[i];
if (!resultInput) {
throw new Error(DUPLICATE_BLOCK_ERROR.replace('%1', 'an input'));
}
for (let j = 0; j < sourceInput.fieldRow.length; j++) {
const sourceField = sourceInput.fieldRow[j];
const resultField = resultInput.fieldRow[j];
if (!resultField) {
throw new Error(DUPLICATE_BLOCK_ERROR.replace('%1', 'a field'));
}
resultField.setValue(sourceField.getValue());
}
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.setCollapsed(sourceBlock.isCollapsed());
result.setInputsInline(sourceBlock.getInputsInline());
result.initSvg();
result.getSvgRoot().setAttribute('visibility', 'hidden');
} finally {
@@ -419,10 +384,7 @@ export class InsertionMarkerManager {
ComponentManager.Capability.DELETE_AREA,
);
if (isDeleteArea) {
return (dragTarget as IDeleteArea).wouldDelete(
this.topBlock,
newCandidate,
);
return (dragTarget as IDeleteArea).wouldDelete(this.topBlock);
}
}
return false;

View File

@@ -8,8 +8,8 @@ import {BlockSvg} from './block_svg.js';
import {IConnectionPreviewer} from './interfaces/i_connection_previewer.js';
import {RenderedConnection} from './rendered_connection.js';
import {WorkspaceSvg} from './workspace_svg.js';
import * as blocks from './serialization/blocks.js';
import * as eventUtils from './events/utils.js';
import * as constants from './constants.js';
import * as renderManagement from './render_management.js';
import * as registry from './registry.js';
import {Renderer as ZelosRenderer} from './renderers/zelos/renderer.js';
@@ -21,8 +21,8 @@ import {ConnectionType} from './connection_type.js';
*/
const DUPLICATE_BLOCK_ERROR =
'The insertion marker previewer tried to create a marker but the result ' +
'is missing %1. If you are using a mutator, make sure your domToMutation ' +
'method is properly defined.';
'is missing a connection. If you are using a mutator, make sure your ' +
'domToMutation method is properly defined.';
export class InsertionMarkerPreviewer implements IConnectionPreviewer {
private readonly workspace: WorkspaceSvg;
@@ -88,6 +88,16 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
eventUtils.disable();
try {
this.hidePreview();
const dragged = draggedConn.getSourceBlock();
const marker = this.createInsertionMarker(dragged);
const markerConn = this.getMatchingConnection(
dragged,
marker,
draggedConn,
);
if (!markerConn) {
throw Error(DUPLICATE_BLOCK_ERROR);
}
// TODO(7898): Instead of special casing, we should change the dragger to
// track the change in distance between the dragged connection and the
@@ -144,60 +154,44 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
};
const originalOffsetInBlock = markerConn.getOffsetInBlock().clone();
renderManagement.finishQueuedRenders().then(() => {
// Position so that the existing block doesn't move.
marker?.positionNearConnection(
markerConn,
originalOffsetToTarget,
originalOffsetInBlock,
);
marker?.getSvgRoot().setAttribute('visibility', 'visible');
eventUtils.disable();
try {
// Position so that the existing block doesn't move.
marker?.positionNearConnection(
markerConn,
originalOffsetToTarget,
originalOffsetInBlock,
);
marker?.getSvgRoot().setAttribute('visibility', 'visible');
} finally {
eventUtils.enable();
}
});
return markerConn;
}
private createInsertionMarker(origBlock: BlockSvg) {
const result = this.workspace.newBlock(origBlock.type);
result.setInsertionMarker(true);
if (origBlock.saveExtraState) {
const state = origBlock.saveExtraState(true);
if (state && result.loadExtraState) {
result.loadExtraState(state);
}
} else if (origBlock.mutationToDom) {
const oldMutationDom = origBlock.mutationToDom();
if (oldMutationDom && result.domToMutation) {
result.domToMutation(oldMutationDom);
}
}
// Copy field values from the other block. These values may impact the
// rendered size of the insertion marker. Note that we do not care about
// child blocks here.
for (let i = 0; i < origBlock.inputList.length; i++) {
const sourceInput = origBlock.inputList[i];
if (sourceInput.name === constants.COLLAPSED_INPUT_NAME) {
continue; // Ignore the collapsed input.
}
const resultInput = result.inputList[i];
if (!resultInput) {
throw new Error(DUPLICATE_BLOCK_ERROR.replace('%1', 'an input'));
}
for (let j = 0; j < sourceInput.fieldRow.length; j++) {
const sourceField = sourceInput.fieldRow[j];
const resultField = resultInput.fieldRow[j];
if (!resultField) {
throw new Error(DUPLICATE_BLOCK_ERROR.replace('%1', 'a field'));
}
resultField.setValue(sourceField.getValue());
}
const blockJson = blocks.save(origBlock, {
addCoordinates: false,
addInputBlocks: false,
addNextBlocks: false,
doFullSerialization: false,
});
if (!blockJson) {
throw new Error(
`Failed to serialize source block. ${origBlock.toDevString()}`,
);
}
const 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.setCollapsed(origBlock.isCollapsed());
result.setInputsInline(origBlock.getInputsInline());
result.initSvg();
result.getSvgRoot().setAttribute('visibility', 'hidden');
return result;

View File

@@ -1,53 +0,0 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {Coordinate} from '../utils/coordinate.js';
import type {BlockSvg} from '../block_svg.js';
// Former goog.module ID: Blockly.IBlockDragger
/**
* A block dragger interface.
*/
export interface IBlockDragger {
/**
* Start dragging a block.
*
* @param currentDragDeltaXY How far the pointer has moved from the position
* at mouse down, in pixel units.
* @param healStack Whether or not to heal the stack after disconnecting.
*/
startDrag(currentDragDeltaXY: Coordinate, healStack: boolean): void;
/**
* Execute a step of block dragging, based on the given event. Update the
* display accordingly.
*
* @param e The most recent move event.
* @param currentDragDeltaXY How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
drag(e: Event, currentDragDeltaXY: Coordinate): void;
/**
* Finish a block drag and put the block back on the workspace.
*
* @param e The mouseup/touchend event.
* @param currentDragDeltaXY How far the pointer has moved from the position
* at the start of the drag, in pixel units.
*/
endDrag(e: Event, currentDragDeltaXY: Coordinate): void;
/**
* 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.
*/
getInsertionMarkers(): BlockSvg[];
/** Sever all links from this object and do any necessary cleanup. */
dispose(): void;
}

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {IconType} from '../icons/icon_types.js';
import {CommentState} from '../icons/comment_icon.js';
import {IIcon, isIcon} from './i_icon.js';
import {Size} from '../utils/size.js';
import {IHasBubble, hasBubble} from './i_has_bubble.js';
import {ISerializable, isSerializable} from './i_serializable.js';
export interface ICommentIcon extends IIcon, IHasBubble, ISerializable {
setText(text: string): void;
getText(): string;
setBubbleSize(size: Size): void;
getBubbleSize(): Size;
saveState(): CommentState;
loadState(state: CommentState): void;
}
/** Checks whether the given object is an ICommentIcon. */
export function isCommentIcon(obj: Object): obj is ICommentIcon {
return (
isIcon(obj) &&
hasBubble(obj) &&
isSerializable(obj) &&
(obj as any)['setText'] !== undefined &&
(obj as any)['getText'] !== undefined &&
(obj as any)['setBubbleSize'] !== undefined &&
(obj as any)['getBubbleSize'] !== undefined &&
obj.getType() === IconType.COMMENT
);
}

View File

@@ -16,4 +16,19 @@ export interface IDeletable {
* @returns True if deletable.
*/
isDeletable(): boolean;
/** Disposes of this object, cleaning up any references or DOM elements. */
dispose(): void;
/** Visually indicates that the object is pending deletion. */
setDeleteStyle(wouldDelete: boolean): void;
}
/** Returns whether the given object is an IDeletable. */
export function isDeletable(obj: any): obj is IDeletable {
return (
obj['isDeletable'] !== undefined &&
obj['dispose'] !== undefined &&
obj['setDeleteStyle'] !== undefined
);
}

View File

@@ -21,9 +21,8 @@ export interface IDeleteArea extends IDragTarget {
* before onDragEnter/onDragOver/onDragExit.
*
* @param element The block or bubble currently being dragged.
* @param couldConnect Whether the element could could connect to another.
* @returns Whether the element provided would be deleted if dropped on this
* area.
*/
wouldDelete(element: IDraggable, couldConnect: boolean): boolean;
wouldDelete(element: IDraggable): boolean;
}

View File

@@ -4,11 +4,69 @@
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.IDraggable
import type {IDeletable} from './i_deletable.js';
import {Coordinate} from '../utils/coordinate';
/**
* The interface for an object that can be dragged.
* Represents an object that can be dragged.
*/
export interface IDraggable extends IDeletable {}
export interface IDraggable extends IDragStrategy {
/**
* Returns the current location of the draggable in workspace
* coordinates.
*
* @returns Coordinate of current location on workspace.
*/
getRelativeToSurfaceXY(): Coordinate;
}
export interface IDragStrategy {
/** Returns true iff the element is currently movable. */
isMovable(): boolean;
/**
* Handles any drag startup (e.g moving elements to the front of the
* workspace).
*
* @param e PointerEvent that started the drag; can be used to
* check modifier keys, etc. May be missing when dragging is
* triggered programatically rather than by user.
*/
startDrag(e?: PointerEvent): void;
/**
* Handles moving elements to the new location, and updating any
* visuals based on that (e.g connection previews for blocks).
*
* @param newLoc Workspace coordinate to which the draggable has
* been dragged.
* @param e PointerEvent that continued the drag. Can be
* used to check modifier keys, etc.
*/
drag(newLoc: Coordinate, e?: PointerEvent): void;
/**
* Handles any drag cleanup, including e.g. connecting or deleting
* blocks.
*
* @param newLoc Workspace coordinate at which the drag finished.
* been dragged.
* @param e PointerEvent that finished the drag. Can be
* used to check modifier keys, etc.
*/
endDrag(e?: PointerEvent): void;
/** Moves the draggable back to where it was at the start of the drag. */
revertDrag(): void;
}
/** Returns whether the given object is an IDraggable or not. */
export function isDraggable(obj: any): obj is IDraggable {
return (
obj.getRelativeToSurfaceXY !== undefined &&
obj.isMovable !== undefined &&
obj.startDrag !== undefined &&
obj.drag !== undefined &&
obj.endDrag !== undefined &&
obj.revertDrag !== undefined
);
}

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Coordinate} from '../utils/coordinate';
export interface IDragger {
/**
* Handles any drag startup.
*
* @param e PointerEvent that started the drag.
*/
onDragStart(e: PointerEvent): void;
/**
* Handles dragging, including calculating where the element should
* actually be moved to.
*
* @param e PointerEvent that continued the drag.
* @param totalDelta The total distance, in pixels, that the mouse
* has moved since the start of the drag.
*/
onDrag(e: PointerEvent, totalDelta: Coordinate): void;
/**
* Handles any drag cleanup.
*
* @param e PointerEvent that finished the drag.
* @param totalDelta The total distance, in pixels, that the mouse
* has moved since the start of the drag.
*/
onDragEnd(e: PointerEvent, totalDelta: Coordinate): void;
}

View File

@@ -9,7 +9,7 @@ export interface IHasBubble {
bubbleIsVisible(): boolean;
/** Sets whether the bubble is open or not. */
setBubbleVisible(visible: boolean): void;
setBubbleVisible(visible: boolean): Promise<void>;
}
/** Type guard that checks whether the given object is a IHasBubble. */

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {ParameterState} from '../serialization/procedures';
import {IProcedureModel} from './i_procedure_model';
/**
@@ -40,4 +41,11 @@ export interface IParameterModel {
/** Sets the procedure model this parameter is associated with. */
setProcedureModel(model: IProcedureModel): this;
/**
* Serializes the state of the parameter to JSON.
*
* @returns JSON serializable state of the parameter.
*/
saveState(): ParameterState;
}

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {State} from '../serialization/procedures.js';
import {IParameterModel} from './i_parameter_model.js';
/**
@@ -60,4 +61,11 @@ export interface IProcedureModel {
* disabled, all procedure caller blocks should be disabled as well.
*/
getEnabled(): boolean;
/**
* Serializes the state of the procedure to JSON.
*
* @returns JSON serializable state of the procedure.
*/
saveState(): State;
}

View File

@@ -6,18 +6,29 @@
// Former goog.module ID: Blockly.ISelectable
import type {IDeletable} from './i_deletable.js';
import type {IMovable} from './i_movable.js';
import type {Workspace} from '../workspace.js';
/**
* The interface for an object that is selectable.
*/
export interface ISelectable extends IDeletable, IMovable {
export interface ISelectable {
id: string;
workspace: Workspace;
/** Select this. Highlight it visually. */
select(): void;
/** Unselect this. Unhighlight it visually. */
unselect(): void;
}
/** Checks whether the given object is an ISelectable. */
export function isSelectable(obj: Object): obj is ISelectable {
return (
typeof (obj as any).id === 'string' &&
(obj as any).workspace !== undefined &&
(obj as any).select !== undefined &&
(obj as any).unselect !== undefined
);
}

View File

@@ -15,14 +15,6 @@ import {ConnectionType} from './connection_type.js';
*/
export const COLLAPSE_CHARS = 30;
/**
* When dragging a block out of a stack, split the stack in two (true), or drag
* out the block healing the stack (false).
*
* @internal
*/
export const DRAG_STACK = true;
/**
* Lookup table for determining the opposite type of a connection.
*

View File

@@ -12,83 +12,7 @@
// Former goog.module ID: Blockly.main
import * as Blockly from './blockly.js';
import * as Msg from './msg.js';
import * as colour from './utils/colour.js';
import * as deprecation from './utils/deprecation.js';
/*
* Aliased functions and properties that used to be on the Blockly namespace.
* Everything in this section is deprecated. Both external and internal code
* should avoid using these functions and use the designated replacements.
* Everything in this section will be removed in a future version of Blockly.
*/
// Add accessors for properties on Blockly that have now been deprecated.
// This will not work in uncompressed mode; it depends on Blockly being
// transpiled from a ES Module object to a plain object by Closure Compiler.
Object.defineProperties(Blockly, {
/**
* The richness of block colours, regardless of the hue.
* Must be in the range of 0 (inclusive) to 1 (exclusive).
*
* @name Blockly.HSV_SATURATION
* @type {number}
* @deprecated Use Blockly.utils.colour.getHsvSaturation() /
* .setHsvSaturation() instead. (July 2023)
* @suppress {checkTypes}
*/
HSV_SATURATION: {
get: function () {
deprecation.warn(
'Blockly.HSV_SATURATION',
'version 10',
'version 11',
'Blockly.utils.colour.getHsvSaturation()',
);
return colour.getHsvSaturation();
},
set: function (newValue) {
deprecation.warn(
'Blockly.HSV_SATURATION',
'version 10',
'version 11',
'Blockly.utils.colour.setHsvSaturation()',
);
colour.setHsvSaturation(newValue);
},
},
/**
* The intensity of block colours, regardless of the hue.
* Must be in the range of 0 (inclusive) to 1 (exclusive).
*
* @name Blockly.HSV_VALUE
* @type {number}
* @deprecated Use Blockly.utils.colour.getHsvValue() / .setHsvValue instead.
* (July 2023)
* @suppress {checkTypes}
*/
HSV_VALUE: {
get: function () {
deprecation.warn(
'Blockly.HSV_VALUE',
'version 10',
'version 11',
'Blockly.utils.colour.getHsvValue()',
);
return colour.getHsvValue();
},
set: function (newValue) {
deprecation.warn(
'Blockly.HSV_VALUE',
'version 10',
'version 11',
'Blockly.utils.colour.setHsvValue()',
);
colour.setHsvValue(newValue);
},
},
});
// If Blockly is compiled with ADVANCED_COMPILATION and/or loaded as a
// CJS or ES module there will not be a Blockly global variable

View File

@@ -12,7 +12,6 @@
// Former goog.module ID: Blockly.Options
import type {BlocklyOptions} from './blockly_options.js';
import * as deprecation from './utils/deprecation.js';
import * as registry from './registry.js';
import {Theme} from './theme.js';
import {Classic} from './theme/classic.js';
@@ -38,6 +37,7 @@ export class Options {
pathToMedia: string;
hasCategories: boolean;
moveOptions: MoveOptions;
/** @deprecated January 2019 */
hasScrollbars: boolean;
hasTrashcan: boolean;
maxTrashcanContents: number;
@@ -143,10 +143,6 @@ export class Options {
pathToMedia = options['media'].endsWith('/')
? options['media']
: options['media'] + '/';
} else if ('path' in options) {
// 'path' is a deprecated option which has been replaced by 'media'.
deprecation.warn('path', 'Nov 2014', 'Jul 2023', 'media');
pathToMedia = (options as any)['path'] + 'media/';
}
const rawOneBasedIndex = options['oneBasedIndex'];
const oneBasedIndex =
@@ -172,7 +168,6 @@ export class Options {
this.pathToMedia = pathToMedia;
this.hasCategories = hasCategories;
this.moveOptions = Options.parseMoveOptions_(options, hasCategories);
/** @deprecated January 2019 */
this.hasScrollbars = !!this.moveOptions.scrollbars;
this.hasTrashcan = hasTrashcan;
this.maxTrashcanContents = maxTrashcanContents;

View File

@@ -8,7 +8,6 @@
import type {Abstract} from './events/events_abstract.js';
import type {Field} from './field.js';
import type {IBlockDragger} from './interfaces/i_block_dragger.js';
import type {IConnectionChecker} from './interfaces/i_connection_checker.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
@@ -24,6 +23,7 @@ import type {ToolboxItem} from './toolbox/toolbox_item.js';
import type {IPaster} from './interfaces/i_paster.js';
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
import type {IConnectionPreviewer} from './interfaces/i_connection_previewer.js';
import type {IDragger} from './interfaces/i_dragger.js';
/**
* A map of maps. With the keys being the type and name of the class we are
@@ -95,7 +95,11 @@ export class Type<_T> {
static METRICS_MANAGER = new Type<IMetricsManager>('metricsManager');
static BLOCK_DRAGGER = new Type<IBlockDragger>('blockDragger');
/**
* Type for an IDragger. Formerly behavior was mostly covered by
* BlockDraggeers, which is why the name is inaccurate.
*/
static BLOCK_DRAGGER = new Type<IDragger>('blockDragger');
/** @internal */
static SERIALIZER = new Type<ISerializer>('serializer');

View File

@@ -6,6 +6,7 @@
import {BlockSvg} from './block_svg.js';
import * as userAgent from './utils/useragent.js';
import * as eventUtils from './events/utils.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/** The set of all blocks in need of rendering which don't have parents. */
@@ -14,6 +15,9 @@ const rootBlocks = new Set<BlockSvg>();
/** The set of all blocks in need of rendering. */
const dirtyBlocks = new WeakSet<BlockSvg>();
/** A map from queued blocks to the event group from when they were queued. */
const eventGroups = new WeakMap<BlockSvg, string>();
/**
* The promise which resolves after the current set of renders is completed. Or
* null if there are no queued renders.
@@ -103,6 +107,7 @@ function alwaysImmediatelyRender() {
*/
function queueBlock(block: BlockSvg) {
dirtyBlocks.add(block);
eventGroups.set(block, eventUtils.getGroup());
const parent = block.getParent();
if (parent) {
queueBlock(parent);
@@ -133,6 +138,15 @@ function doRenders(workspace?: WorkspaceSvg) {
const blockOrigin = block.getRelativeToSurfaceXY();
block.updateComponentLocations(blockOrigin);
}
for (const block of blocks) {
const oldGroup = eventUtils.getGroup();
const newGroup = eventGroups.get(block);
if (newGroup) eventUtils.setGroup(newGroup);
block.bumpNeighbours();
eventUtils.setGroup(oldGroup);
}
for (const block of blocks) {
dequeueBlock(block);
@@ -144,6 +158,7 @@ function doRenders(workspace?: WorkspaceSvg) {
function dequeueBlock(block: BlockSvg) {
rootBlocks.delete(block);
dirtyBlocks.delete(block);
eventGroups.delete(block);
for (const child of block.getChildren(false)) {
dequeueBlock(child);
}
@@ -170,6 +185,7 @@ function shouldRenderRootBlock(block: BlockSvg): boolean {
*/
function renderBlock(block: BlockSvg) {
if (!dirtyBlocks.has(block)) return;
if (!block.initialized) return;
for (const child of block.getChildren(false)) {
renderBlock(child);
}

View File

@@ -445,19 +445,20 @@ export class RenderedConnection extends Connection {
const {parentConnection, childConnection} =
this.getParentAndChildConnections();
if (!parentConnection || !childConnection) return;
const existingGroup = eventUtils.getGroup();
if (!existingGroup) eventUtils.setGroup(true);
const parent = parentConnection.getSourceBlock() as BlockSvg;
const child = childConnection.getSourceBlock() as BlockSvg;
super.disconnectInternal(setParent);
// Rerender the parent so that it may reflow.
if (parent.rendered) {
parent.queueRender();
}
if (child.rendered) {
child.updateDisabled();
child.queueRender();
// Reset visibility, since the child is now a top block.
child.getSvgRoot().style.display = 'block';
}
parent.queueRender();
child.updateDisabled();
child.queueRender();
// Reset visibility, since the child is now a top block.
child.getSvgRoot().style.display = 'block';
eventUtils.setGroup(existingGroup);
}
/**
@@ -500,29 +501,10 @@ export class RenderedConnection extends Connection {
const parentBlock = this.getSourceBlock();
const childBlock = renderedChildConnection.getSourceBlock();
const parentRendered = parentBlock.rendered;
const childRendered = childBlock.rendered;
if (parentRendered) {
parentBlock.updateDisabled();
}
if (childRendered) {
childBlock.updateDisabled();
}
if (parentRendered && childRendered) {
if (
this.type === ConnectionType.NEXT_STATEMENT ||
this.type === ConnectionType.PREVIOUS_STATEMENT
) {
// Child block may need to square off its corners if it is in a stack.
// Rendering a child will render its parent.
childBlock.queueRender();
} else {
// Child block does not change shape. Rendering the parent node will
// move its connected children into position.
parentBlock.queueRender();
}
}
parentBlock.updateDisabled();
childBlock.updateDisabled();
childBlock.queueRender();
// The input the child block is connected to (if any).
const parentInput = parentBlock.getInputWithBlock(childBlock);
@@ -548,8 +530,6 @@ export class RenderedConnection extends Connection {
) {
const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_;
child!.unplug();
// Bump away.
this.sourceBlock_.bumpNeighbours();
}
}
@@ -563,9 +543,7 @@ export class RenderedConnection extends Connection {
*/
override setCheck(check: string | string[] | null): RenderedConnection {
super.setCheck(check);
if (this.sourceBlock_.rendered) {
this.sourceBlock_.queueRender();
}
this.sourceBlock_.queueRender();
return this;
}
}

View File

@@ -21,7 +21,6 @@ import {Types} from '../measurables/types.js';
import {isDynamicShape, isNotch, isPuzzleTab} from './constants.js';
import type {ConstantProvider, Notch, PuzzleTab} from './constants.js';
import type {RenderInfo} from './info.js';
import * as deprecation from '../../utils/deprecation.js';
import {ConnectionType} from '../../connection_type.js';
/**
@@ -70,16 +69,6 @@ export class Drawer {
this.recordSizeOnBlock_();
}
/**
* Hide icons that were marked as hidden.
*
* @deprecated Manually hiding icons is no longer necessary. To be removed
* in v11.
*/
protected hideHiddenIcons_() {
deprecation.warn('hideHiddenIcons_', 'v10', 'v11');
}
/**
* Save sizing information back to the block
* Most of the rendering information can be thrown away at the end of the

View File

@@ -12,18 +12,12 @@ import type {ConstantProvider} from '../common/constants.js';
import {Measurable} from './base.js';
import {Types} from './types.js';
import {hasBubble} from '../../interfaces/i_has_bubble.js';
/**
* An object containing information about the space an icon takes up during
* rendering.
*/
export class Icon extends Measurable {
/**
* @deprecated Will be removed in v11. Create a subclass of the Icon
* measurable if this data is necessary for you.
*/
isVisible: boolean;
flipRtl = false;
/**
@@ -39,7 +33,6 @@ export class Icon extends Measurable {
) {
super(constants);
this.isVisible = hasBubble(icon) && icon.bubbleIsVisible();
this.type |= Types.ICON;
const size = icon.getSize();

View File

@@ -1,33 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.minimalist.ConstantProvider
import {ConstantProvider as BaseConstantProvider} from '../common/constants.js';
import * as deprecation from '../../utils/deprecation.js';
/**
* An object that provides constants for rendering blocks in the minimalist
* renderer.
*
* @deprecated Use Blockly.blockRendering.ConstantProvider instead.
* To be removed in v11.
*/
export class ConstantProvider extends BaseConstantProvider {
/**
* @deprecated Use Blockly.blockRendering.ConstantProvider instead.
* To be removed in v11.
*/
constructor() {
super();
deprecation.warn(
'Blockly.minimalist.ConstantProvider',
'v10',
'v11',
'Blockly.blockRendering.ConstantProvider',
);
}
}

View File

@@ -1,39 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.minimalist.Drawer
import type {BlockSvg} from '../../block_svg.js';
import {Drawer as BaseDrawer} from '../common/drawer.js';
import * as deprecation from '../../utils/deprecation.js';
import type {RenderInfo} from './info.js';
/**
* An object that draws a block based on the given rendering information.
*
* @deprecated Use Blockly.blockRendering.Drawer instead.
* To be removed in v11.
*/
export class Drawer extends BaseDrawer {
/**
* @param block The block to render.
* @param info An object containing all information needed to render this
* block.
*
* @deprecated Use Blockly.blockRendering.Drawer instead.
* To be removed in v11.
*/
constructor(block: BlockSvg, info: RenderInfo) {
super(block, info);
deprecation.warn(
'Blockly.minimalist.Drawer',
'v10',
'v11',
'Blockly.blockRendering.Drawer',
);
}
}

View File

@@ -1,53 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.minimalist.RenderInfo
import type {BlockSvg} from '../../block_svg.js';
import {RenderInfo as BaseRenderInfo} from '../common/info.js';
import * as deprecation from '../../utils/deprecation.js';
import type {Renderer} from './renderer.js';
/**
* An object containing all sizing information needed to draw this block.
*
* This measure pass does not propagate changes to the block (although fields
* may choose to rerender when getSize() is called). However, calling it
* repeatedly may be expensive.
*
* @deprecated Use Blockly.blockRendering.RenderInfo instead. To be removed
* in v11.
*/
export class RenderInfo extends BaseRenderInfo {
// Exclamation is fine b/c this is assigned by the super constructor.
protected override renderer_!: Renderer;
/**
* @param renderer The renderer in use.
* @param block The block to measure.
* @deprecated Use Blockly.blockRendering.RenderInfo instead. To be removed
* in v11.
*/
constructor(renderer: Renderer, block: BlockSvg) {
super(renderer, block);
deprecation.warn(
'Blockly.minimalist.RenderInfo',
'v10',
'v11',
'Blockly.blockRendering.RenderInfo',
);
}
/**
* Get the block renderer in use.
*
* @returns The block renderer in use.
*/
override getRenderer(): Renderer {
return this.renderer_;
}
}

View File

@@ -1,16 +0,0 @@
/** @file Re-exports of Blockly.minimalist.* modules. */
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.minimalist
import {ConstantProvider} from './constants.js';
import {Drawer} from './drawer.js';
import {RenderInfo} from './info.js';
import {Renderer} from './renderer.js';
export {ConstantProvider, Drawer, Renderer, RenderInfo};

View File

@@ -1,76 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.minimalist.Renderer
import type {BlockSvg} from '../../block_svg.js';
import * as blockRendering from '../common/block_rendering.js';
import type {RenderInfo as BaseRenderInfo} from '../common/info.js';
import {Renderer as BaseRenderer} from '../common/renderer.js';
import * as deprecation from '../../utils/deprecation.js';
import {ConstantProvider} from './constants.js';
import {Drawer} from './drawer.js';
import {RenderInfo} from './info.js';
/**
* The minimalist renderer.
*
* @deprecated Use Blockly.blockRendering.Renderer instead. To be removed
* in v11.
*/
export class Renderer extends BaseRenderer {
/**
* @param name The renderer name.
* @deprecated Use Blockly.blockRendering.Renderer instead. To be removed
* in v11.
*/
constructor(name: string) {
super(name);
deprecation.warn(
'Blockly.minimalist.Renderer',
'v10',
'v11',
'Blockly.blockRendering.Renderer',
);
}
/**
* Create a new instance of the renderer's constant provider.
*
* @returns The constant provider.
*/
protected override makeConstants_(): ConstantProvider {
return new ConstantProvider();
}
/**
* Create a new instance of the renderer's render info object.
*
* @param block The block to measure.
* @returns The render info object.
*/
protected override makeRenderInfo_(block: BlockSvg): RenderInfo {
return new RenderInfo(this, block);
}
/**
* Create a new instance of the renderer's drawer.
*
* @param block The block to render.
* @param info An object containing all information needed to render this
* block.
* @returns The drawer.
*/
protected override makeDrawer_(
block: BlockSvg,
info: BaseRenderInfo,
): Drawer {
return new Drawer(block, info as RenderInfo);
}
}
blockRendering.register('minimalist', Renderer);

View File

@@ -16,6 +16,7 @@ import * as procedures from './serialization/procedures.js';
import * as registry from './serialization/registry.js';
import * as variables from './serialization/variables.js';
import * as workspaces from './serialization/workspaces.js';
import * as workspaceComments from './serialization/workspace_comments.js';
import {ISerializer} from './interfaces/i_serializer.js';
export {
@@ -26,5 +27,6 @@ export {
registry,
variables,
workspaces,
workspaceComments,
ISerializer,
};

View File

@@ -9,6 +9,8 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import type {Connection} from '../connection.js';
import {MANUALLY_DISABLED} from '../constants.js';
import * as deprecation from '../utils/deprecation.js';
import * as eventUtils from '../events/utils.js';
import {inputTypes} from '../inputs/input_types.js';
import {isSerializable} from '../interfaces/i_serializable.js';
@@ -53,6 +55,7 @@ export interface State {
movable?: boolean;
editable?: boolean;
enabled?: boolean;
disabledReasons?: string[];
inline?: boolean;
data?: string;
extraState?: AnyDuringMigration;
@@ -158,7 +161,7 @@ function saveAttributes(block: Block, state: State) {
state['collapsed'] = true;
}
if (!block.isEnabled()) {
state['enabled'] = false;
state['disabledReasons'] = Array.from(block.getDisabledReasons());
}
if (!block.isOwnDeletable()) {
state['deletable'] = false;
@@ -520,7 +523,18 @@ function loadAttributes(block: Block, state: State) {
block.setEditable(false);
}
if (state['enabled'] === false) {
block.setEnabled(false);
deprecation.warn(
'enabled',
'v11',
'v12',
'disabledReasons with the value ["' + MANUALLY_DISABLED + '"]',
);
block.setDisabledReason(true, MANUALLY_DISABLED);
}
if (Array.isArray(state['disabledReasons'])) {
for (const reason of state['disabledReasons']) {
block.setDisabledReason(true, reason);
}
}
if (state['inline'] !== undefined) {
block.setInputsInline(state['inline']);

View File

@@ -20,3 +20,6 @@ export const PROCEDURES = 75;
* The priority for deserializing blocks.
*/
export const BLOCKS = 50;
/** The priority for deserializing workspace comments. */
export const WORKSPACE_COMMENTS = 25;

View File

@@ -10,87 +10,75 @@ import type {ISerializer} from '../interfaces/i_serializer.js';
import * as priorities from './priorities.js';
import type {Workspace} from '../workspace.js';
/**
* Representation of a procedure data model.
*/
/** Represents the state of a procedure model. */
export interface State {
// TODO: This should also handle enabled.
id: string;
name: string;
returnTypes: string[] | null;
parameters?: ParameterState[];
[key: string]: unknown;
}
/**
* Representation of a parameter data model.
*/
/** Represents the state of a parameter model. */
export interface ParameterState {
id: string;
name: string;
types?: string[];
[key: string]: unknown;
}
/**
* A newable signature for an IProcedureModel.
*
* Refer to
* https://www.typescriptlang.org/docs/handbook/2/generics.html#using-class-types-in-generics
* https://www.typescriptlang.org/docs/handbook/interfaces.html#difference-between-the-static-and-instance-sides-of-classes
* for what is going on with this.
*/
type ProcedureModelConstructor<ProcedureModel extends IProcedureModel> = new (
workspace: Workspace,
name: string,
id: string,
) => ProcedureModel;
interface ProcedureModelConstructor<ProcedureModel extends IProcedureModel> {
new (workspace: Workspace, name: string, id: string): ProcedureModel;
/**
* Deserializes the JSON state and returns a procedure model.
*
* @param state The state to deserialize.
* @param workspace The workspace to load the procedure model into.
* @returns The constructed procedure model.
*/
loadState(state: Object, workspace: Workspace): ProcedureModel;
}
/**
* A newable signature for an IParameterModel.
*
* Refer to
* https://www.typescriptlang.org/docs/handbook/2/generics.html#using-class-types-in-generics
* https://www.typescriptlang.org/docs/handbook/interfaces.html#difference-between-the-static-and-instance-sides-of-classes
* for what is going on with this.
*/
type ParameterModelConstructor<ParameterModel extends IParameterModel> = new (
workspace: Workspace,
name: string,
id: string,
) => ParameterModel;
interface ParameterModelConstructor<ParameterModel extends IParameterModel> {
new (workspace: Workspace, name: string, id: string): ParameterModel;
/**
* Serializes the given IProcedureModel to JSON.
*
* @internal
*/
export function saveProcedure(proc: IProcedureModel): State {
const state: State = {
id: proc.getId(),
name: proc.getName(),
returnTypes: proc.getReturnTypes(),
};
if (!proc.getParameters().length) return state;
state.parameters = proc.getParameters().map((param) => saveParameter(param));
return state;
/**
* Deserializes the JSON state and returns a parameter model.
*
* @param state The state to deserialize.
* @param workspace The workspace to load the parameter model into.
* @returns The constructed parameter model.
*/
loadState(state: Object, workspace: Workspace): ParameterModel;
}
/**
* Serializes the given IParameterModel to JSON.
*
* @internal
* Serializes the given IProcedureModel to JSON.
*/
export function saveParameter(param: IParameterModel): ParameterState {
const state: ParameterState = {
id: param.getId(),
name: param.getName(),
};
if (!param.getTypes().length) return state;
state.types = param.getTypes();
export function saveProcedure(proc: IProcedureModel): State {
const state: State = proc.saveState();
if (!proc.getParameters().length) return state;
state.parameters = proc.getParameters().map((param) => param.saveState());
return state;
}
/**
* Deserializes the given procedure model State from JSON.
*
* @internal
*/
export function loadProcedure<
ProcedureModel extends IProcedureModel,
@@ -101,36 +89,17 @@ export function loadProcedure<
state: State,
workspace: Workspace,
): ProcedureModel {
const proc = new procedureModelClass(
workspace,
state.name,
state.id,
).setReturnTypes(state.returnTypes);
const proc = procedureModelClass.loadState(state, workspace);
if (!state.parameters) return proc;
for (const [index, param] of state.parameters.entries()) {
proc.insertParameter(
loadParameter(parameterModelClass, param, workspace),
parameterModelClass.loadState(param, workspace),
index,
);
}
return proc;
}
/**
* Deserializes the given ParameterState from JSON.
*
* @internal
*/
export function loadParameter<ParameterModel extends IParameterModel>(
parameterModelClass: ParameterModelConstructor<ParameterModel>,
state: ParameterState,
workspace: Workspace,
): ParameterModel {
const model = new parameterModelClass(workspace, state.name, state.id);
if (state.types) model.setTypes(state.types);
return model;
}
/** Serializer for saving and loading procedure state. */
export class ProcedureSerializer<
ProcedureModel extends IProcedureModel,

View File

@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ISerializer} from '../interfaces/i_serializer.js';
import {Workspace} from '../workspace.js';
import * as priorities from './priorities.js';
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import * as eventUtils from '../events/utils.js';
import {Coordinate} from '../utils/coordinate.js';
import * as serializationRegistry from './registry.js';
import {Size} from '../utils/size.js';
export interface State {
id?: string;
text?: string;
x?: number;
y?: number;
width?: number;
height?: number;
collapsed?: boolean;
editable?: boolean;
movable?: boolean;
deletable?: boolean;
}
/** Serializes the state of the given comment to JSON. */
export function save(
comment: WorkspaceComment,
{
addCoordinates = false,
saveIds = true,
}: {
addCoordinates?: boolean;
saveIds?: boolean;
} = {},
): State {
const workspace = comment.workspace;
const state: State = Object.create(null);
state.height = comment.getSize().height;
state.width = comment.getSize().width;
if (saveIds) state.id = comment.id;
if (addCoordinates) {
const loc = comment.getRelativeToSurfaceXY();
state.x = workspace.RTL ? workspace.getWidth() - loc.x : loc.x;
state.y = loc.y;
}
if (comment.getText()) state.text = comment.getText();
if (comment.isCollapsed()) state.collapsed = true;
if (!comment.isOwnEditable()) state.editable = false;
if (!comment.isOwnMovable()) state.movable = false;
if (!comment.isOwnDeletable()) state.deletable = false;
return state;
}
/** Appends the comment defined by the given state to the given workspace. */
export function append(
state: State,
workspace: Workspace,
{recordUndo = false}: {recordUndo?: boolean} = {},
): WorkspaceComment {
const prevRecordUndo = eventUtils.getRecordUndo();
eventUtils.setRecordUndo(recordUndo);
const comment = workspace.newComment(state.id);
if (state.text !== undefined) comment.setText(state.text);
if (state.x !== undefined || state.y !== undefined) {
const defaultLoc = comment.getRelativeToSurfaceXY();
let x = state.x ?? defaultLoc.x;
x = workspace.RTL ? workspace.getWidth() - x : x;
const y = state.y ?? defaultLoc.y;
comment.moveTo(new Coordinate(x, y));
}
if (state.width !== undefined || state.height) {
const defaultSize = comment.getSize();
comment.setSize(
new Size(
state.width ?? defaultSize.width,
state.height ?? defaultSize.height,
),
);
}
if (state.collapsed !== undefined) comment.setCollapsed(state.collapsed);
if (state.editable !== undefined) comment.setEditable(state.editable);
if (state.movable !== undefined) comment.setMovable(state.movable);
if (state.deletable !== undefined) comment.setDeletable(state.deletable);
eventUtils.setRecordUndo(prevRecordUndo);
return comment;
}
// Alias to disambiguate saving within the serializer.
const saveComment = save;
/** Serializer for saving and loading workspace comment state. */
export class WorkspaceCommentSerializer implements ISerializer {
priority = priorities.WORKSPACE_COMMENTS;
/**
* Returns the state of all workspace comments in the given workspace.
*/
save(workspace: Workspace): State[] | null {
const commentStates = [];
for (const comment of workspace.getTopComments()) {
const state = saveComment(comment as AnyDuringMigration, {
addCoordinates: true,
saveIds: true,
});
if (state) commentStates.push(state);
}
return commentStates.length ? commentStates : null;
}
/**
* Deserializes the comments defined by the given state into the given
* workspace.
*/
load(state: State[], workspace: Workspace) {
for (const commentState of state) {
append(commentState, workspace, {recordUndo: eventUtils.getRecordUndo()});
}
}
/** Disposes of any comments that exist on the given workspace. */
clear(workspace: Workspace) {
for (const comment of workspace.getTopComments()) {
comment.dispose();
}
}
}
serializationRegistry.register(
'workspaceComments',
new WorkspaceCommentSerializer(),
);

View File

@@ -11,9 +11,12 @@ import * as clipboard from './clipboard.js';
import * as common from './common.js';
import {Gesture} from './gesture.js';
import {ICopyData, isCopyable} from './interfaces/i_copyable.js';
import {isDeletable} from './interfaces/i_deletable.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {KeyCodes} from './utils/keycodes.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import {isDraggable} from './interfaces/i_draggable.js';
import * as eventUtils from './events/utils.js';
/**
* Object holding the names of the default shortcut items.
@@ -59,6 +62,7 @@ export function registerDelete() {
return (
!workspace.options.readOnly &&
selected != null &&
isDeletable(selected) &&
selected.isDeletable()
);
},
@@ -72,7 +76,14 @@ export function registerDelete() {
if (Gesture.inProgress()) {
return false;
}
(common.getSelected() as BlockSvg).checkAndDelete();
const selected = common.getSelected();
if (selected instanceof BlockSvg) {
selected.checkAndDelete();
} else if (isDeletable(selected) && selected.isDeletable()) {
eventUtils.setGroup(true);
selected.dispose();
eventUtils.setGroup(false);
}
return true;
},
keyCodes: [KeyCodes.DELETE, KeyCodes.BACKSPACE],
@@ -105,7 +116,9 @@ export function registerCopy() {
!workspace.options.readOnly &&
!Gesture.inProgress() &&
selected != null &&
isDeletable(selected) &&
selected.isDeletable() &&
isDraggable(selected) &&
selected.isMovable() &&
isCopyable(selected)
);
@@ -148,19 +161,32 @@ export function registerCut() {
!workspace.options.readOnly &&
!Gesture.inProgress() &&
selected != null &&
selected instanceof BlockSvg &&
isDeletable(selected) &&
selected.isDeletable() &&
isDraggable(selected) &&
selected.isMovable() &&
!selected.workspace!.isFlyout
);
},
callback(workspace) {
const selected = common.getSelected();
if (!selected || !isCopyable(selected)) return false;
copyData = selected.toCopyData();
copyWorkspace = workspace;
(selected as BlockSvg).checkAndDelete();
return true;
if (selected instanceof BlockSvg) {
copyData = selected.toCopyData();
copyWorkspace = workspace;
selected.checkAndDelete();
return true;
} else if (
isDeletable(selected) &&
selected.isDeletable() &&
isCopyable(selected)
) {
copyData = selected.toCopyData();
copyWorkspace = workspace;
selected.dispose();
return true;
}
return false;
},
keyCodes: [ctrlX, altX, metaX],
};
@@ -216,10 +242,11 @@ export function registerUndo() {
preconditionFn(workspace) {
return !workspace.options.readOnly && !Gesture.inProgress();
},
callback(workspace) {
callback(workspace, e) {
// 'z' for undo 'Z' is for redo.
(workspace as WorkspaceSvg).hideChaff();
workspace.undo(false);
e.preventDefault();
return true;
},
keyCodes: [ctrlZ, altZ, metaZ],
@@ -254,10 +281,11 @@ export function registerRedo() {
preconditionFn(workspace) {
return !Gesture.inProgress() && !workspace.options.readOnly;
},
callback(workspace) {
callback(workspace, e) {
// 'z' for undo 'Z' is for redo.
(workspace as WorkspaceSvg).hideChaff();
workspace.undo(true);
e.preventDefault();
return true;
},
keyCodes: [ctrlShiftZ, altShiftZ, metaShiftZ, ctrlY],

View File

@@ -41,9 +41,9 @@ import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
import * as toolbox from '../utils/toolbox.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {ToolboxCategory} from './category.js';
import {CollapsibleToolboxCategory} from './collapsible_category.js';
import {isDeletable} from '../interfaces/i_deletable.js';
/**
* Class for a Toolbox.
@@ -532,17 +532,15 @@ export class Toolbox
* before onDragEnter/onDragOver/onDragExit.
*
* @param element The block or bubble currently being dragged.
* @param _couldConnect Whether the element could could connect to another.
* @returns Whether the element provided would be deleted if dropped on this
* area.
*/
override wouldDelete(element: IDraggable, _couldConnect: boolean): boolean {
override wouldDelete(element: IDraggable): boolean {
if (element instanceof BlockSvg) {
const block = element;
// Prefer dragging to the toolbox over connecting to other blocks.
this.updateWouldDelete_(!block.getParent() && block.isDeletable());
} else {
this.updateWouldDelete_(element.isDeletable());
this.updateWouldDelete_(isDeletable(element) && element.isDeletable());
}
return this.wouldDelete_;
}

View File

@@ -656,6 +656,7 @@ export class Trashcan
delete json['x'];
delete json['y'];
delete json['enabled'];
delete json['disabledReasons'];
if (json['icons'] && json['icons']['comment']) {
const comment = json['icons']['comment'];

View File

@@ -6,7 +6,6 @@
// Former goog.module ID: Blockly.utils.dom
import * as deprecation from './deprecation.js';
import type {Svg} from './svg.js';
/**
@@ -154,24 +153,6 @@ export function insertAfter(newNode: Element, refNode: Element) {
}
}
/**
* Whether a node contains another node.
*
* @param parent The node that should contain the other node.
* @param descendant The node to test presence of.
* @returns Whether the parent node contains the descendant node.
* @deprecated Use native 'contains' DOM method.
*/
export function containsNode(parent: Node, descendant: Node): boolean {
deprecation.warn(
'Blockly.utils.dom.containsNode',
'version 10',
'version 11',
'Use native "contains" DOM method',
);
return parent.contains(descendant);
}
/**
* Sets the CSS transform property on an element. This function sets the
* non-vendor-prefixed and vendor-prefixed versions for backwards compatibility

View File

@@ -43,4 +43,20 @@ export class Size {
}
return a.width === b.width && a.height === b.height;
}
/**
* Returns a new size with the maximum width and height values out of both
* sizes.
*/
static max(a: Size, b: Size): Size {
return new Size(Math.max(a.width, b.width), Math.max(a.height, b.height));
}
/**
* Returns a new size with the minimum width and height values out of both
* sizes.
*/
static min(a: Size, b: Size): Size {
return new Size(Math.min(a.width, b.width), Math.min(a.height, b.height));
}
}

View File

@@ -6,27 +6,6 @@
// Former goog.module ID: Blockly.utils.string
import * as deprecation from './deprecation.js';
/**
* Fast prefix-checker.
* Copied from Closure's goog.string.startsWith.
*
* @param str The string to check.
* @param prefix A string to look for at the start of `str`.
* @returns True if `str` begins with `prefix`.
* @deprecated Use built-in **string.startsWith** instead.
*/
export function startsWith(str: string, prefix: string): boolean {
deprecation.warn(
'Blockly.utils.string.startsWith()',
'April 2022',
'April 2023',
'Use built-in string.startsWith',
);
return str.startsWith(prefix);
}
/**
* Given an array of strings, return the length of the shortest one.
*

View File

@@ -21,6 +21,7 @@ export interface BlockInfo {
type?: string;
gap?: string | number;
disabled?: string | boolean;
disabledReasons?: string[];
enabled?: boolean;
id?: string;
x?: number;

View File

@@ -30,7 +30,7 @@ import * as math from './utils/math.js';
import type * as toolbox from './utils/toolbox.js';
import {VariableMap} from './variable_map.js';
import type {VariableModel} from './variable_model.js';
import type {WorkspaceComment} from './workspace_comment.js';
import {WorkspaceComment} from './comments/workspace_comment.js';
import {IProcedureMap} from './interfaces/i_procedure_map.js';
import {ObservableProcedureMap} from './observable_procedure_map.js';
@@ -515,6 +515,20 @@ export class Workspace implements IASTNodeLocation {
'monkey-patched in by blockly.ts',
);
}
/**
* Obtain a newly created comment.
*
* @param id Optional ID. Use this ID if provided, otherwise create a new
* ID.
* @returns The created comment.
*/
newComment(id?: string): WorkspaceComment {
throw new Error(
'The implementation of newComment should be ' +
'monkey-patched in by blockly.ts',
);
}
/* eslint-enable */
/**

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