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

This commit is contained in:
Aaron Dodson
2024-11-11 10:46:53 -08:00
28 changed files with 3281 additions and 1056 deletions

View File

@@ -42,7 +42,7 @@ jobs:
path: _deploy/
- name: Deploy to App Engine
uses: google-github-actions/deploy-appengine@v2.1.3
uses: google-github-actions/deploy-appengine@v2.1.4
# For parameters see:
# https://github.com/google-github-actions/deploy-appengine#inputs
with:

View File

@@ -412,6 +412,24 @@ const LISTS_GETINDEX = {
this.appendDummyInput()
.appendField(modeMenu, 'MODE')
.appendField('', 'SPACE');
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options: this.WHERE_OPTIONS,
}) as FieldDropdown;
menu.setValidator(
/** @param value The input value. */
function (this: FieldDropdown, value: string) {
const oldValue: string | null = this.getValue();
const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END';
const newAt = value === 'FROM_START' || value === 'FROM_END';
if (newAt !== oldAt) {
const block = this.getSourceBlock() as GetIndexBlock;
block.updateAt_(newAt);
}
return undefined;
},
);
this.appendDummyInput().appendField(menu, 'WHERE');
this.appendDummyInput('AT');
if (Msg['LISTS_GET_INDEX_TAIL']) {
this.appendDummyInput('TAIL').appendField(Msg['LISTS_GET_INDEX_TAIL']);
@@ -577,31 +595,6 @@ const LISTS_GETINDEX = {
} else {
this.appendDummyInput('AT');
}
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options: this.WHERE_OPTIONS,
}) as FieldDropdown;
menu.setValidator(
/**
* @param value The input value.
* @returns Null if the field has been replaced; otherwise undefined.
*/
function (this: FieldDropdown, value: string) {
const newAt = value === 'FROM_START' || value === 'FROM_END';
// The 'isAt' variable is available due to this function being a
// closure.
if (newAt !== isAt) {
const block = this.getSourceBlock() as GetIndexBlock;
block.updateAt_(newAt);
// This menu has been destroyed and replaced. Update the
// replacement.
block.setFieldValue(value, 'WHERE');
return null;
}
return undefined;
},
);
this.getInput('AT')!.appendField(menu, 'WHERE');
if (Msg['LISTS_GET_INDEX_TAIL']) {
this.moveInputBefore('TAIL', null);
}
@@ -644,6 +637,24 @@ const LISTS_SETINDEX = {
this.appendDummyInput()
.appendField(operationDropdown, 'MODE')
.appendField('', 'SPACE');
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options: this.WHERE_OPTIONS,
}) as FieldDropdown;
menu.setValidator(
/** @param value The input value. */
function (this: FieldDropdown, value: string) {
const oldValue: string | null = this.getValue();
const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END';
const newAt = value === 'FROM_START' || value === 'FROM_END';
if (newAt !== oldAt) {
const block = this.getSourceBlock() as SetIndexBlock;
block.updateAt_(newAt);
}
return undefined;
},
);
this.appendDummyInput().appendField(menu, 'WHERE');
this.appendDummyInput('AT');
this.appendValueInput('TO').appendField(Msg['LISTS_SET_INDEX_INPUT_TO']);
this.setInputsInline(true);
@@ -756,36 +767,10 @@ const LISTS_SETINDEX = {
} else {
this.appendDummyInput('AT');
}
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options: this.WHERE_OPTIONS,
}) as FieldDropdown;
menu.setValidator(
/**
* @param value The input value.
* @returns Null if the field has been replaced; otherwise undefined.
*/
function (this: FieldDropdown, value: string) {
const newAt = value === 'FROM_START' || value === 'FROM_END';
// The 'isAt' variable is available due to this function being a
// closure.
if (newAt !== isAt) {
const block = this.getSourceBlock() as SetIndexBlock;
block.updateAt_(newAt);
// This menu has been destroyed and replaced. Update the
// replacement.
block.setFieldValue(value, 'WHERE');
return null;
}
return undefined;
},
);
this.moveInputBefore('AT', 'TO');
if (this.getInput('ORDINAL')) {
this.moveInputBefore('ORDINAL', 'TO');
}
this.getInput('AT')!.appendField(menu, 'WHERE');
},
};
blocks['lists_setIndex'] = LISTS_SETINDEX;
@@ -818,7 +803,30 @@ const LISTS_GETSUBLIST = {
this.appendValueInput('LIST')
.setCheck('Array')
.appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST']);
const createMenu = (n: 1 | 2): FieldDropdown => {
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options:
this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'],
}) as FieldDropdown;
menu.setValidator(
/** @param value The input value. */
function (this: FieldDropdown, value: string) {
const oldValue: string | null = this.getValue();
const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END';
const newAt = value === 'FROM_START' || value === 'FROM_END';
if (newAt !== oldAt) {
const block = this.getSourceBlock() as GetSublistBlock;
block.updateAt_(n, newAt);
}
return undefined;
},
);
return menu;
};
this.appendDummyInput('WHERE1_INPUT').appendField(createMenu(1), 'WHERE1');
this.appendDummyInput('AT1');
this.appendDummyInput('WHERE2_INPUT').appendField(createMenu(2), 'WHERE2');
this.appendDummyInput('AT2');
if (Msg['LISTS_GET_SUBLIST_TAIL']) {
this.appendDummyInput('TAIL').appendField(Msg['LISTS_GET_SUBLIST_TAIL']);
@@ -896,35 +904,10 @@ const LISTS_GETSUBLIST = {
} else {
this.appendDummyInput('AT' + n);
}
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options:
this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'],
}) as FieldDropdown;
menu.setValidator(
/**
* @param value The input value.
* @returns Null if the field has been replaced; otherwise undefined.
*/
function (this: FieldDropdown, value: string) {
const newAt = value === 'FROM_START' || value === 'FROM_END';
// The 'isAt' variable is available due to this function being a
// closure.
if (newAt !== isAt) {
const block = this.getSourceBlock() as GetSublistBlock;
block.updateAt_(n, newAt);
// This menu has been destroyed and replaced.
// Update the replacement.
block.setFieldValue(value, 'WHERE' + n);
return null;
}
},
);
this.getInput('AT' + n)!.appendField(menu, 'WHERE' + n);
if (n === 1) {
this.moveInputBefore('AT1', 'AT2');
this.moveInputBefore('AT1', 'WHERE2_INPUT');
if (this.getInput('ORDINAL1')) {
this.moveInputBefore('ORDINAL1', 'AT2');
this.moveInputBefore('ORDINAL1', 'WHERE2_INPUT');
}
}
if (Msg['LISTS_GET_SUBLIST_TAIL']) {

View File

@@ -216,7 +216,30 @@ const GET_SUBSTRING_BLOCK = {
this.appendValueInput('STRING')
.setCheck('String')
.appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT']);
const createMenu = (n: 1 | 2): FieldDropdown => {
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options:
this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'],
}) as FieldDropdown;
menu.setValidator(
/** @param value The input value. */
function (this: FieldDropdown, value: any): null | undefined {
const oldValue: string | null = this.getValue();
const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END';
const newAt = value === 'FROM_START' || value === 'FROM_END';
if (newAt !== oldAt) {
const block = this.getSourceBlock() as GetSubstringBlock;
block.updateAt_(n, newAt);
}
return undefined;
},
);
return menu;
};
this.appendDummyInput('WHERE1_INPUT').appendField(createMenu(1), 'WHERE1');
this.appendDummyInput('AT1');
this.appendDummyInput('WHERE2_INPUT').appendField(createMenu(2), 'WHERE2');
this.appendDummyInput('AT2');
if (Msg['TEXT_GET_SUBSTRING_TAIL']) {
this.appendDummyInput('TAIL').appendField(Msg['TEXT_GET_SUBSTRING_TAIL']);
@@ -288,37 +311,10 @@ const GET_SUBSTRING_BLOCK = {
this.removeInput('TAIL', true);
this.appendDummyInput('TAIL').appendField(Msg['TEXT_GET_SUBSTRING_TAIL']);
}
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
options:
this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'],
}) as FieldDropdown;
menu.setValidator(
/**
* @param value The input value.
* @returns Null if the field has been replaced; otherwise undefined.
*/
function (this: FieldDropdown, value: any): null | undefined {
const newAt = value === 'FROM_START' || value === 'FROM_END';
// The 'isAt' variable is available due to this function being a
// closure.
if (newAt !== isAt) {
const block = this.getSourceBlock() as GetSubstringBlock;
block.updateAt_(n, newAt);
// This menu has been destroyed and replaced.
// Update the replacement.
block.setFieldValue(value, 'WHERE' + n);
return null;
}
return undefined;
},
);
this.getInput('AT' + n)!.appendField(menu, 'WHERE' + n);
if (n === 1) {
this.moveInputBefore('AT1', 'AT2');
this.moveInputBefore('AT1', 'WHERE2_INPUT');
if (this.getInput('ORDINAL1')) {
this.moveInputBefore('ORDINAL1', 'AT2');
this.moveInputBefore('ORDINAL1', 'WHERE2_INPUT');
}
}
},

View File

@@ -1472,9 +1472,9 @@ export class BlockSvg
if (conn.isConnected() && neighbour.isConnected()) continue;
if (conn.isSuperior()) {
neighbour.bumpAwayFrom(conn);
neighbour.bumpAwayFrom(conn, /* initiatedByThis = */ false);
} else {
conn.bumpAwayFrom(neighbour);
conn.bumpAwayFrom(neighbour, /* initiatedByThis = */ true);
}
}
}

View File

@@ -9,6 +9,7 @@ import * as touch from '../touch.js';
import {browserEvents} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as drag from '../utils/drag.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
@@ -62,6 +63,8 @@ export class TextInputBubble extends Bubble {
20 + Bubble.DOUBLE_BORDER,
);
private editable = true;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -95,6 +98,21 @@ export class TextInputBubble extends Bubble {
this.onTextChange();
}
/** Sets whether or not the text in the bubble is editable. */
setEditable(editable: boolean) {
this.editable = editable;
if (this.editable) {
this.textArea.removeAttribute('readonly');
} else {
this.textArea.setAttribute('readonly', '');
}
}
/** Returns whether or not the text in the bubble is editable. */
isEditable(): boolean {
return this.editable;
}
/** Adds a change listener to be notified when this bubble's text changes. */
addTextChangeListener(listener: () => void) {
this.textChangeListeners.push(listener);
@@ -224,7 +242,8 @@ export class TextInputBubble extends Bubble {
return;
}
this.workspace.startDrag(
drag.start(
this.workspace,
e,
new Coordinate(
this.workspace.RTL ? -this.getSize().width : this.getSize().width,
@@ -264,7 +283,7 @@ export class TextInputBubble extends Bubble {
/** Handles pointer move events on the resize target. */
private onResizePointerMove(e: PointerEvent) {
const delta = this.workspace.moveDrag(e);
const delta = drag.move(this.workspace, e);
this.setSize(
new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y),
false,

View File

@@ -11,6 +11,7 @@ import * as layers from '../layers.js';
import * as touch from '../touch.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as drag from '../utils/drag.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -524,8 +525,8 @@ export class CommentView implements IRenderedElement {
this.preResizeSize = this.getSize();
// TODO(#7926): Move this into a utils file.
this.workspace.startDrag(
drag.start(
this.workspace,
e,
new Coordinate(
this.workspace.RTL ? -this.getSize().width : this.getSize().width,
@@ -569,8 +570,7 @@ export class CommentView implements IRenderedElement {
/** 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 size = this.workspace.moveDrag(e);
const size = drag.move(this.workspace, e);
this.setSizeWithoutFiringEvents(
new Size(this.workspace.RTL ? -size.x : size.x, size.y),
);
@@ -623,6 +623,7 @@ export class CommentView implements IRenderedElement {
* event on the foldout icon.
*/
private onFoldoutDown(e: PointerEvent) {
touch.clearTouchIdentifier();
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
@@ -738,6 +739,7 @@ export class CommentView implements IRenderedElement {
* delete icon.
*/
private onDeleteDown(e: PointerEvent) {
touch.clearTouchIdentifier();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;

View File

@@ -214,11 +214,11 @@ export class Connection implements IASTNodeLocationWithBlock {
* Called when an attempted connection fails. NOP by default (i.e. for
* headless workspaces).
*
* @param _otherConnection Connection that this connection failed to connect
* to.
* @param _superiorConnection Connection that this connection failed to connect
* to. The provided connection should be the superior connection.
* @internal
*/
onFailedConnect(_otherConnection: Connection) {}
onFailedConnect(_superiorConnection: Connection) {}
// NOP
/**

View File

@@ -1086,57 +1086,68 @@ export abstract class Field<T = any>
return;
}
const classValidation = this.doClassValidation_(newValue);
const classValue = this.processValidation_(
newValue,
classValidation,
fireChangeEvent,
);
if (classValue instanceof Error) {
if (doLogging) console.log('invalid class validation, return');
return;
// Field validators are allowed to make changes to the workspace, which
// should get grouped with the field value change event.
const existingGroup = eventUtils.getGroup();
if (!existingGroup) {
eventUtils.setGroup(true);
}
const localValidation = this.getValidator()?.call(this, classValue);
const localValue = this.processValidation_(
classValue,
localValidation,
fireChangeEvent,
);
if (localValue instanceof Error) {
if (doLogging) console.log('invalid local validation, return');
return;
}
const source = this.sourceBlock_;
if (source && source.disposed) {
if (doLogging) console.log('source disposed, return');
return;
}
const oldValue = this.getValue();
if (oldValue === localValue) {
if (doLogging) console.log('same, doValueUpdate_, return');
this.doValueUpdate_(localValue);
return;
}
this.doValueUpdate_(localValue);
if (fireChangeEvent && source && eventUtils.isEnabled()) {
eventUtils.fire(
new (eventUtils.get(EventType.BLOCK_CHANGE))(
source,
'field',
this.name || null,
oldValue,
localValue,
),
try {
const classValidation = this.doClassValidation_(newValue);
const classValue = this.processValidation_(
newValue,
classValidation,
fireChangeEvent,
);
if (classValue instanceof Error) {
if (doLogging) console.log('invalid class validation, return');
return;
}
const localValidation = this.getValidator()?.call(this, classValue);
const localValue = this.processValidation_(
classValue,
localValidation,
fireChangeEvent,
);
if (localValue instanceof Error) {
if (doLogging) console.log('invalid local validation, return');
return;
}
const source = this.sourceBlock_;
if (source && source.disposed) {
if (doLogging) console.log('source disposed, return');
return;
}
const oldValue = this.getValue();
if (oldValue === localValue) {
if (doLogging) console.log('same, doValueUpdate_, return');
this.doValueUpdate_(localValue);
return;
}
this.doValueUpdate_(localValue);
if (fireChangeEvent && source && eventUtils.isEnabled()) {
eventUtils.fire(
new (eventUtils.get(EventType.BLOCK_CHANGE))(
source,
'field',
this.name || null,
oldValue,
localValue,
),
);
}
if (this.isDirty_) {
this.forceRerender();
}
if (doLogging) console.log(this.value_);
} finally {
eventUtils.setGroup(existingGroup);
}
if (this.isDirty_) {
this.forceRerender();
}
if (doLogging) console.log(this.value_);
}
/**

View File

@@ -95,6 +95,15 @@ export class FieldDropdown extends Field<string> {
private selectedOption!: MenuOption;
override clickTarget_: SVGElement | null = null;
/**
* The y offset from the top of the field to the top of the image, if an image
* is selected.
*/
protected static IMAGE_Y_OFFSET = 5;
/** The total vertical padding above and below an image. */
protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2;
/**
* @param menuGenerator A non-empty array of options for a dropdown list, or a
* function which generates these options. Also accepts Field.SKIP_SETUP
@@ -128,8 +137,8 @@ export class FieldDropdown extends Field<string> {
if (menuGenerator === Field.SKIP_SETUP) return;
if (Array.isArray(menuGenerator)) {
validateOptions(menuGenerator);
const trimmed = trimOptions(menuGenerator);
this.validateOptions(menuGenerator);
const trimmed = this.trimOptions(menuGenerator);
this.menuGenerator_ = trimmed.options;
this.prefixField = trimmed.prefix || null;
this.suffixField = trimmed.suffix || null;
@@ -401,7 +410,7 @@ export class FieldDropdown extends Field<string> {
if (useCache && this.generatedOptions) return this.generatedOptions;
this.generatedOptions = this.menuGenerator_();
validateOptions(this.generatedOptions);
this.validateOptions(this.generatedOptions);
return this.generatedOptions;
}
@@ -520,7 +529,7 @@ export class FieldDropdown extends Field<string> {
const hasBorder = !!this.borderRect_;
const height = Math.max(
hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
imageHeight + IMAGE_Y_PADDING,
imageHeight + FieldDropdown.IMAGE_Y_PADDING,
);
const xPadding = hasBorder
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
@@ -661,6 +670,127 @@ export class FieldDropdown extends Field<string> {
// override the static fromJson method.
return new this(options.options, undefined, options);
}
/**
* Factor out common words in statically defined options.
* Create prefix and/or suffix labels.
*/
protected trimOptions(options: MenuOption[]): {
options: MenuOption[];
prefix?: string;
suffix?: string;
} {
let hasImages = false;
const trimmedOptions = options.map(([label, value]): MenuOption => {
if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value];
}
hasImages = true;
// Copy the image properties so they're not influenced by the original.
// NOTE: No need to deep copy since image properties are only 1 level deep.
const imageLabel =
label.alt !== null
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
: {...label};
return [imageLabel, value];
});
if (hasImages || options.length < 2) return {options: trimmedOptions};
const stringOptions = trimmedOptions as [string, string][];
const stringLabels = stringOptions.map(([label]) => label);
const shortest = utilsString.shortestStringLength(stringLabels);
const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest);
const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest);
if (
(!prefixLength && !suffixLength) ||
shortest <= prefixLength + suffixLength
) {
// One or more strings will entirely vanish if we proceed. Abort.
return {options: stringOptions};
}
const prefix = prefixLength
? stringLabels[0].substring(0, prefixLength - 1)
: undefined;
const suffix = suffixLength
? stringLabels[0].substr(1 - suffixLength)
: undefined;
return {
options: this.applyTrim(stringOptions, prefixLength, suffixLength),
prefix,
suffix,
};
}
/**
* Use the calculated prefix and suffix lengths to trim all of the options in
* the given array.
*
* @param options Array of option tuples:
* (human-readable text or image, language-neutral name).
* @param prefixLength The length of the common prefix.
* @param suffixLength The length of the common suffix
* @returns A new array with all of the option text trimmed.
*/
private applyTrim(
options: [string, string][],
prefixLength: number,
suffixLength: number,
): MenuOption[] {
return options.map(([text, value]) => [
text.substring(prefixLength, text.length - suffixLength),
value,
]);
}
/**
* Validates the data structure to be processed as an options list.
*
* @param options The proposed dropdown options.
* @throws {TypeError} If proposed options are incorrectly structured.
*/
protected validateOptions(options: MenuOption[]) {
if (!Array.isArray(options)) {
throw TypeError('FieldDropdown options must be an array.');
}
if (!options.length) {
throw TypeError('FieldDropdown options must not be an empty array.');
}
let foundError = false;
for (let i = 0; i < options.length; i++) {
const tuple = options[i];
if (!Array.isArray(tuple)) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must be an array.
Found: ${tuple}`,
);
} else if (typeof tuple[1] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${tuple[1]} in: ${tuple}`,
);
} else if (
tuple[0] &&
typeof tuple[0] !== 'string' &&
typeof tuple[0].src !== 'string'
) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string
label or image description. Found ${tuple[0]} in: ${tuple}`,
);
}
}
if (foundError) {
throw TypeError('Found invalid FieldDropdown options.');
}
}
}
/**
@@ -721,147 +851,4 @@ export interface FieldDropdownFromJsonConfig extends FieldDropdownConfig {
*/
export type FieldDropdownValidator = FieldValidator<string>;
/**
* The y offset from the top of the field to the top of the image, if an image
* is selected.
*/
const IMAGE_Y_OFFSET = 5;
/** The total vertical padding above and below an image. */
const IMAGE_Y_PADDING: number = IMAGE_Y_OFFSET * 2;
/**
* Factor out common words in statically defined options.
* Create prefix and/or suffix labels.
*/
function trimOptions(options: MenuOption[]): {
options: MenuOption[];
prefix?: string;
suffix?: string;
} {
let hasImages = false;
const trimmedOptions = options.map(([label, value]): MenuOption => {
if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value];
}
hasImages = true;
// Copy the image properties so they're not influenced by the original.
// NOTE: No need to deep copy since image properties are only 1 level deep.
const imageLabel =
label.alt !== null
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
: {...label};
return [imageLabel, value];
});
if (hasImages || options.length < 2) return {options: trimmedOptions};
const stringOptions = trimmedOptions as [string, string][];
const stringLabels = stringOptions.map(([label]) => label);
const shortest = utilsString.shortestStringLength(stringLabels);
const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest);
const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest);
if (
(!prefixLength && !suffixLength) ||
shortest <= prefixLength + suffixLength
) {
// One or more strings will entirely vanish if we proceed. Abort.
return {options: stringOptions};
}
const prefix = prefixLength
? stringLabels[0].substring(0, prefixLength - 1)
: undefined;
const suffix = suffixLength
? stringLabels[0].substr(1 - suffixLength)
: undefined;
return {
options: applyTrim(stringOptions, prefixLength, suffixLength),
prefix,
suffix,
};
}
/**
* Use the calculated prefix and suffix lengths to trim all of the options in
* the given array.
*
* @param options Array of option tuples:
* (human-readable text or image, language-neutral name).
* @param prefixLength The length of the common prefix.
* @param suffixLength The length of the common suffix
* @returns A new array with all of the option text trimmed.
*/
function applyTrim(
options: [string, string][],
prefixLength: number,
suffixLength: number,
): MenuOption[] {
return options.map(([text, value]) => [
text.substring(prefixLength, text.length - suffixLength),
value,
]);
}
/**
* Validates the data structure to be processed as an options list.
*
* @param options The proposed dropdown options.
* @throws {TypeError} If proposed options are incorrectly structured.
*/
function validateOptions(options: MenuOption[]) {
if (!Array.isArray(options)) {
throw TypeError('FieldDropdown options must be an array.');
}
if (!options.length) {
throw TypeError('FieldDropdown options must not be an empty array.');
}
let foundError = false;
for (let i = 0; i < options.length; i++) {
const tuple = options[i];
if (!Array.isArray(tuple)) {
foundError = true;
console.error(
'Invalid option[' +
i +
']: Each FieldDropdown option must be an ' +
'array. Found: ',
tuple,
);
} else if (typeof tuple[1] !== 'string') {
foundError = true;
console.error(
'Invalid option[' +
i +
']: Each FieldDropdown option id must be ' +
'a string. Found ' +
tuple[1] +
' in: ',
tuple,
);
} else if (
tuple[0] &&
typeof tuple[0] !== 'string' &&
typeof tuple[0].src !== 'string'
) {
foundError = true;
console.error(
'Invalid option[' +
i +
']: Each FieldDropdown option must have a ' +
'string label or image description. Found' +
tuple[0] +
' in: ',
tuple,
);
}
}
if (foundError) {
throw TypeError('Found invalid FieldDropdown options.');
}
}
fieldRegistry.register('field_dropdown', FieldDropdown);

View File

@@ -28,8 +28,8 @@ import {
UnattachedFieldError,
} from './field.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Size} from './utils/size.js';
import * as userAgent from './utils/useragent.js';
@@ -630,22 +630,22 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
/** Resize the editor to fit the text. */
protected resizeEditor_() {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
const div = WidgetDiv.getDiv();
const bBox = this.getScaledBBox();
div!.style.width = bBox.right - bBox.left + 'px';
div!.style.height = bBox.bottom - bBox.top + 'px';
renderManagement.finishQueuedRenders().then(() => {
const block = this.getSourceBlock();
if (!block) throw new UnattachedFieldError();
const div = WidgetDiv.getDiv();
const bBox = this.getScaledBBox();
div!.style.width = bBox.right - bBox.left + 'px';
div!.style.height = bBox.bottom - bBox.top + 'px';
// In RTL mode block fields and LTR input fields the left edge moves,
// whereas the right edge is fixed. Reposition the editor.
const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
const xy = new Coordinate(x, bBox.top);
// In RTL mode block fields and LTR input fields the left edge moves,
// whereas the right edge is fixed. Reposition the editor.
const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
const y = bBox.top;
div!.style.left = xy.x + 'px';
div!.style.top = xy.y + 'px';
div!.style.left = `${x}px`;
div!.style.top = `${y}px`;
});
}
/**
@@ -657,7 +657,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
* div.
*/
override repositionForWindowResize(): boolean {
const block = this.getSourceBlock();
const block = this.getSourceBlock()?.getRootBlock();
// This shouldn't be possible. We should never have a WidgetDiv if not using
// rendered blocks.
if (!(block instanceof BlockSvg)) return false;

View File

@@ -8,7 +8,6 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import {TextBubble} from '../bubbles/text_bubble.js';
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
@@ -47,12 +46,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
*/
static readonly WEIGHT = 3;
/** The bubble used to show editable text to the user. */
/** The bubble used to show comment text to the user. */
private textInputBubble: TextInputBubble | null = null;
/** The bubble used to show non-editable text to the user. */
private textBubble: TextBubble | null = null;
/** The text of this comment. */
private text = '';
@@ -118,7 +114,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
override dispose() {
super.dispose();
this.textInputBubble?.dispose();
this.textBubble?.dispose();
}
override getWeight(): number {
@@ -133,7 +128,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
super.applyColour();
const colour = (this.sourceBlock as BlockSvg).style.colourPrimary;
this.textInputBubble?.setColour(colour);
this.textBubble?.setColour(colour);
}
/**
@@ -153,7 +147,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
super.onLocationChange(blockOrigin);
const anchorLocation = this.getAnchorLocation();
this.textInputBubble?.setAnchorLocation(anchorLocation);
this.textBubble?.setAnchorLocation(anchorLocation);
}
/** Sets the text of this comment. Updates any bubbles if they are visible. */
@@ -170,7 +163,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
);
this.text = text;
this.textInputBubble?.setText(this.text);
this.textBubble?.setText(this.text);
}
/** Returns the text of this comment. */
@@ -302,6 +294,18 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
* to update the state of this icon in response to changes in the bubble.
*/
private showEditableBubble() {
this.createBubble();
this.textInputBubble?.addTextChangeListener(() => this.onTextChange());
this.textInputBubble?.addSizeChangeListener(() => this.onSizeChange());
}
/** Shows the non editable text bubble for this comment. */
private showNonEditableBubble() {
this.createBubble();
this.textInputBubble?.setEditable(false);
}
protected createBubble() {
this.textInputBubble = new TextInputBubble(
this.sourceBlock.workspace as WorkspaceSvg,
this.getAnchorLocation(),
@@ -309,26 +313,12 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
);
this.textInputBubble.setText(this.getText());
this.textInputBubble.setSize(this.bubbleSize, true);
this.textInputBubble.addTextChangeListener(() => this.onTextChange());
this.textInputBubble.addSizeChangeListener(() => this.onSizeChange());
}
/** Shows the non editable text bubble for this comment. */
private showNonEditableBubble() {
this.textBubble = new TextBubble(
this.getText(),
this.sourceBlock.workspace as WorkspaceSvg,
this.getAnchorLocation(),
this.getBubbleOwnerRect(),
);
}
/** Hides any open bubbles owned by this comment. */
private hideBubble() {
this.textInputBubble?.dispose();
this.textInputBubble = null;
this.textBubble?.dispose();
this.textBubble = null;
}
/**

View File

@@ -117,59 +117,85 @@ export class RenderedConnection extends Connection {
* Move the block(s) belonging to the connection to a point where they don't
* visually interfere with the specified connection.
*
* @param staticConnection The connection to move away from.
* @param superiorConnection The connection to move away from. The provided
* connection should be the superior connection and should not be
* connected to this connection.
* @param initiatedByThis Whether or not the block group that was manipulated
* recently causing bump checks is associated with the inferior
* connection. Defaults to false.
* @internal
*/
bumpAwayFrom(staticConnection: RenderedConnection) {
bumpAwayFrom(
superiorConnection: RenderedConnection,
initiatedByThis = false,
) {
if (this.sourceBlock_.workspace.isDragging()) {
// Don't move blocks around while the user is doing the same.
return;
}
// Move the root block.
let rootBlock = this.sourceBlock_.getRootBlock();
if (rootBlock.isInFlyout) {
let offsetX =
config.snapRadius + Math.floor(Math.random() * BUMP_RANDOMNESS);
let offsetY =
config.snapRadius + Math.floor(Math.random() * BUMP_RANDOMNESS);
/* eslint-disable-next-line @typescript-eslint/no-this-alias */
const inferiorConnection = this;
const superiorRootBlock = superiorConnection.sourceBlock_.getRootBlock();
const inferiorRootBlock = inferiorConnection.sourceBlock_.getRootBlock();
if (superiorRootBlock.isInFlyout || inferiorRootBlock.isInFlyout) {
// Don't move blocks around in a flyout.
return;
}
let reverse = false;
if (!rootBlock.isMovable()) {
// Can't bump an uneditable block away.
let moveInferior = true;
if (!inferiorRootBlock.isMovable()) {
// Can't bump an immovable block away.
// Check to see if the other block is movable.
rootBlock = staticConnection.getSourceBlock().getRootBlock();
if (!rootBlock.isMovable()) {
if (!superiorRootBlock.isMovable()) {
// Neither block is movable, abort operation.
return;
} else {
// Only the superior block group is movable.
moveInferior = false;
// The superior block group moves in the opposite direction.
offsetX = -offsetX;
offsetY = -offsetY;
}
} else if (superiorRootBlock.isMovable()) {
// Both block groups are movable. The one on the inferior side will be
// moved to make space for the superior one. However, it's possible that
// both groups of blocks have an inferior connection that bumps into a
// superior connection on the other group, which could result in both
// groups moving in the same direction and eventually bumping each other
// again. It would be better if one group of blocks could consistently
// move in an orthogonal direction from the other, so that they become
// separated in the end. We can designate one group the "initiator" if
// it's the one that was most recently manipulated, causing inputs to be
// checked for bumpable neighbors. As a useful heuristic, in the case
// where the inferior connection belongs to the initiator group, moving it
// in the orthogonal direction will separate the blocks better.
if (initiatedByThis) {
offsetY = -offsetY;
}
// Swap the connections and move the 'static' connection instead.
/* eslint-disable-next-line @typescript-eslint/no-this-alias */
staticConnection = this;
reverse = true;
}
const staticConnection = moveInferior
? superiorConnection
: inferiorConnection;
const dynamicConnection = moveInferior
? inferiorConnection
: superiorConnection;
const dynamicRootBlock = moveInferior
? inferiorRootBlock
: superiorRootBlock;
// Raise it to the top for extra visibility.
const selected = common.getSelected() == rootBlock;
if (!selected) rootBlock.addSelect();
let dx =
staticConnection.x +
config.snapRadius +
Math.floor(Math.random() * BUMP_RANDOMNESS) -
this.x;
let dy =
staticConnection.y +
config.snapRadius +
Math.floor(Math.random() * BUMP_RANDOMNESS) -
this.y;
if (reverse) {
// When reversing a bump due to an uneditable block, bump up.
dy = -dy;
const selected = common.getSelected() === dynamicRootBlock;
if (!selected) dynamicRootBlock.addSelect();
if (dynamicRootBlock.RTL) {
offsetX = -offsetX;
}
if (rootBlock.RTL) {
dx =
staticConnection.x -
config.snapRadius -
Math.floor(Math.random() * BUMP_RANDOMNESS) -
this.x;
}
rootBlock.moveBy(dx, dy, ['bump']);
if (!selected) rootBlock.removeSelect();
const dx = staticConnection.x + offsetX - dynamicConnection.x;
const dy = staticConnection.y + offsetY - dynamicConnection.y;
dynamicRootBlock.moveBy(dx, dy, ['bump']);
if (!selected) dynamicRootBlock.removeSelect();
}
/**
@@ -413,11 +439,11 @@ export class RenderedConnection extends Connection {
* Bumps this connection away from the other connection. Called when an
* attempted connection fails.
*
* @param otherConnection Connection that this connection failed to connect
* to.
* @param superiorConnection Connection that this connection failed to connect
* to. The provided connection should be the superior connection.
* @internal
*/
override onFailedConnect(otherConnection: Connection) {
override onFailedConnect(superiorConnection: Connection) {
const block = this.getSourceBlock();
if (eventUtils.getRecordUndo()) {
const group = eventUtils.getGroup();
@@ -425,7 +451,7 @@ export class RenderedConnection extends Connection {
function (this: RenderedConnection) {
if (!block.isDisposed() && !block.getParent()) {
eventUtils.setGroup(group);
this.bumpAwayFrom(otherConnection as RenderedConnection);
this.bumpAwayFrom(superiorConnection as RenderedConnection);
eventUtils.setGroup(false);
}
}.bind(this),

View File

@@ -45,31 +45,27 @@ export class ShortcutRegistry {
* Registers a keyboard shortcut.
*
* @param shortcut The shortcut for this key code.
* @param opt_allowOverrides True to prevent a warning when overriding an
* @param allowOverrides True to prevent a warning when overriding an
* already registered item.
* @throws {Error} if a shortcut with the same name already exists.
*/
register(shortcut: KeyboardShortcut, opt_allowOverrides?: boolean) {
register(shortcut: KeyboardShortcut, allowOverrides?: boolean) {
const registeredShortcut = this.shortcuts.get(shortcut.name);
if (registeredShortcut && !opt_allowOverrides) {
if (registeredShortcut && !allowOverrides) {
throw new Error(`Shortcut named "${shortcut.name}" already exists.`);
}
this.shortcuts.set(shortcut.name, shortcut);
const keyCodes = shortcut.keyCodes;
if (keyCodes && keyCodes.length > 0) {
for (let i = 0; i < keyCodes.length; i++) {
this.addKeyMapping(
keyCodes[i],
shortcut.name,
!!shortcut.allowCollision,
);
if (keyCodes?.length) {
for (const keyCode of keyCodes) {
this.addKeyMapping(keyCode, shortcut.name, !!shortcut.allowCollision);
}
}
}
/**
* Unregisters a keyboard shortcut registered with the given key code. This
* Unregisters a keyboard shortcut registered with the given name. This
* will also remove any key mappings that reference this shortcut.
*
* @param shortcutName The name of the shortcut to unregister.
@@ -92,27 +88,34 @@ export class ShortcutRegistry {
/**
* Adds a mapping between a keycode and a keyboard shortcut.
*
* Normally only one shortcut can be mapped to any given keycode,
* but setting allowCollisions to true allows a keyboard to be
* mapped to multiple shortcuts. In that case, when onKeyDown is
* called with the given keystroke, it will process the mapped
* shortcuts in reverse order, from the most- to least-recently
* mapped).
*
* @param keyCode The key code for the keyboard shortcut. If registering a key
* code with a modifier (ex: ctrl+c) use
* ShortcutRegistry.registry.createSerializedKey;
* @param shortcutName The name of the shortcut to execute when the given
* keycode is pressed.
* @param opt_allowCollision True to prevent an error when adding a shortcut
* @param allowCollision True to prevent an error when adding a shortcut
* to a key that is already mapped to a shortcut.
* @throws {Error} if the given key code is already mapped to a shortcut.
*/
addKeyMapping(
keyCode: string | number | KeyCodes,
shortcutName: string,
opt_allowCollision?: boolean,
allowCollision?: boolean,
) {
keyCode = `${keyCode}`;
const shortcutNames = this.keyMap.get(keyCode);
if (shortcutNames && !opt_allowCollision) {
if (shortcutNames && !allowCollision) {
throw new Error(
`Shortcut named "${shortcutName}" collides with shortcuts "${shortcutNames}"`,
);
} else if (shortcutNames && opt_allowCollision) {
} else if (shortcutNames && allowCollision) {
shortcutNames.unshift(shortcutName);
} else {
this.keyMap.set(keyCode, [shortcutName]);
@@ -127,19 +130,19 @@ export class ShortcutRegistry {
* ShortcutRegistry.registry.createSerializedKey;
* @param shortcutName The name of the shortcut to execute when the given
* keycode is pressed.
* @param opt_quiet True to not console warn when there is no shortcut to
* @param quiet True to not console warn when there is no shortcut to
* remove.
* @returns True if a key mapping was removed, false otherwise.
*/
removeKeyMapping(
keyCode: string,
shortcutName: string,
opt_quiet?: boolean,
quiet?: boolean,
): boolean {
const shortcutNames = this.keyMap.get(keyCode);
if (!shortcutNames) {
if (!opt_quiet) {
if (!quiet) {
console.warn(
`No keyboard shortcut named "${shortcutName}" registered with key code "${keyCode}"`,
);
@@ -155,7 +158,7 @@ export class ShortcutRegistry {
}
return true;
}
if (!opt_quiet) {
if (!quiet) {
console.warn(
`No keyboard shortcut named "${shortcutName}" registered with key code "${keyCode}"`,
);
@@ -172,7 +175,7 @@ export class ShortcutRegistry {
*/
removeAllKeyMappings(shortcutName: string) {
for (const keyCode of this.keyMap.keys()) {
this.removeKeyMapping(keyCode, shortcutName, true);
this.removeKeyMapping(keyCode, shortcutName, /* quiet= */ true);
}
}
@@ -219,6 +222,21 @@ export class ShortcutRegistry {
/**
* Handles key down events.
*
* - Any `KeyboardShortcut`(s) mapped to the keycodes that cause
* event `e` to be fired will be processed, in order from least-
* to most-recently registered.
* - If the shortcut's `preconditionFn` exists it will be called.
* If `preconditionFn` returns false the shortcut's `callback`
* function will be skipped. Processing will continue with the
* next shortcut, if any.
* - The shortcut's `callback` function will then be called. If it
* returns true, processing will terminate and `onKeyDown` will
* return true. If it returns false, processing will continue
* with with the next shortcut, if any.
* - If all registered shortcuts for the given keycode have been
* processed without any having returned true, `onKeyDown` will
* return false.
*
* @param workspace The main workspace where the event was captured.
* @param e The key down event.
* @returns True if the event was handled, false otherwise.
@@ -226,17 +244,17 @@ export class ShortcutRegistry {
onKeyDown(workspace: WorkspaceSvg, e: KeyboardEvent): boolean {
const key = this.serializeKeyEvent_(e);
const shortcutNames = this.getShortcutNamesByKeyCode(key);
if (!shortcutNames) {
return false;
}
for (let i = 0, shortcutName; (shortcutName = shortcutNames[i]); i++) {
if (!shortcutNames) return false;
for (const shortcutName of shortcutNames) {
const shortcut = this.shortcuts.get(shortcutName);
if (!shortcut?.preconditionFn || shortcut?.preconditionFn(workspace)) {
// If the key has been handled, stop processing shortcuts.
if (shortcut?.callback && shortcut?.callback(workspace, e, shortcut)) {
return true;
}
if (
!shortcut ||
(shortcut.preconditionFn && !shortcut.preconditionFn(workspace))
) {
continue;
}
// If the key has been handled, stop processing shortcuts.
if (shortcut.callback?.(workspace, e, shortcut)) return true;
}
return false;
}
@@ -301,7 +319,7 @@ export class ShortcutRegistry {
* @throws {Error} if the modifier is not in the valid modifiers list.
*/
private checkModifiers_(modifiers: KeyCodes[]) {
for (let i = 0, modifier; (modifier = modifiers[i]); i++) {
for (const modifier of modifiers) {
if (!(modifier in ShortcutRegistry.modifierKeys)) {
throw new Error(modifier + ' is not a valid modifier key.');
}
@@ -313,7 +331,7 @@ export class ShortcutRegistry {
*
* @param keyCode Number code representing the key.
* @param modifiers List of modifier key codes to be used with the key. All
* valid modifiers can be found in the ShortcutRegistry.modifierKeys.
* valid modifiers can be found in the `ShortcutRegistry.modifierKeys`.
* @returns The serialized key code for the given modifiers and key.
*/
createSerializedKey(keyCode: number, modifiers: KeyCodes[] | null): string {
@@ -344,12 +362,59 @@ export class ShortcutRegistry {
}
export namespace ShortcutRegistry {
/** Interface defining a keyboard shortcut. */
export interface KeyboardShortcut {
callback?: (p1: WorkspaceSvg, p2: Event, p3: KeyboardShortcut) => boolean;
/**
* The function to be called when the shorctut is invoked.
*
* @param workspace The `WorkspaceSvg` when the shortcut was
* invoked.
* @param e The event that caused the shortcut to be activated.
* @param shortcut The `KeyboardShortcut` that was activated
* (i.e., the one this callback is attached to).
* @returns Returning true ends processing of the invoked keycode.
* Returning false causes processing to continue with the
* next-most-recently registered shortcut for the invoked
* keycode.
*/
callback?: (
workspace: WorkspaceSvg,
e: Event,
shortcut: KeyboardShortcut,
) => boolean;
/** The name of the shortcut. Should be unique. */
name: string;
preconditionFn?: (p1: WorkspaceSvg) => boolean;
/**
* A function to be called when the shortcut is invoked, before
* calling `callback`, to decide if this shortcut is applicable in
* the current situation.
*
* @param workspace The `WorkspaceSvg` where the shortcut was
* invoked.
* @returns True iff `callback` function should be called.
*/
preconditionFn?: (workspace: WorkspaceSvg) => boolean;
/** Optional arbitray extra data attached to the shortcut. */
metadata?: object;
/**
* Optional list of key codes to be bound (via
* ShortcutRegistry.prototype.addKeyMapping) to this shortcut.
*/
keyCodes?: (number | string)[];
/**
* Value of `allowCollision` to pass to `addKeyMapping` when
* binding this shortcut's `.keyCodes` (if any).
*
* N.B.: this is only used for binding keycodes at the time this
* shortcut is initially registered, not for any subsequent
* `addKeyMapping` calls that happen to reference this shortcut's
* name.
*/
allowCollision?: boolean;
}

74
core/utils/drag.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {Coordinate} from './coordinate.js';
const workspaceToDragDelta: WeakMap<WorkspaceSvg, Coordinate> = new WeakMap();
/**
* Convert from mouse coordinates to workspace coordinates.
*
* @param workspace The workspace where the pointer event is occurring.
* @param e The pointer event with the source coordinates.
*/
function mouseToWorkspacePoint(
workspace: WorkspaceSvg,
e: PointerEvent,
): SVGPoint {
const point = browserEvents.mouseToSvg(
e,
workspace.getParentSvg(),
workspace.getInverseScreenCTM(),
);
// Fix scale of mouse event.
point.x /= workspace.scale;
point.y /= workspace.scale;
return point;
}
/**
* Start tracking a drag of an object on this workspace by recording the offset
* between the pointer's current location and the object's starting location.
*
* Used for resizing block comments and workspace comments.
*
* @param workspace The workspace where the drag is occurring.
* @param e Pointer down event.
* @param xy Starting location of object.
*/
export function start(
workspace: WorkspaceSvg,
e: PointerEvent,
xy: Coordinate,
) {
const point = mouseToWorkspacePoint(workspace, e);
// Record the starting offset between the bubble's location and the mouse.
workspaceToDragDelta.set(workspace, Coordinate.difference(xy, point));
}
/**
* Compute the new position of a dragged object in this workspace based on the
* current pointer position and the offset between the pointer's starting
* location and the object's starting location.
*
* The start function should have be called previously, when the drag started.
*
* Used for resizing block comments and workspace comments.
*
* @param workspace The workspace where the drag is occurring.
* @param e Pointer move event.
* @returns New location of object.
*/
export function move(workspace: WorkspaceSvg, e: PointerEvent): Coordinate {
const point = mouseToWorkspacePoint(workspace, e);
const dragDelta = workspaceToDragDelta.get(workspace);
if (!dragDelta) {
throw new Error('Drag not initialized');
}
return Coordinate.sum(dragDelta, point);
}

View File

@@ -13,6 +13,8 @@
*/
// Former goog.module ID: Blockly.utils.Rect
import {Coordinate} from './coordinate.js';
/**
* Class for representing rectangular regions.
*/
@@ -30,10 +32,21 @@ export class Rect {
public right: number,
) {}
/**
* Creates a new copy of this rectangle.
*
* @returns A copy of this Rect.
*/
clone(): Rect {
return new Rect(this.top, this.bottom, this.left, this.right);
}
/** Returns the height of this rectangle. */
getHeight(): number {
return this.bottom - this.top;
}
/** Returns the width of this rectangle. */
getWidth(): number {
return this.right - this.left;
}
@@ -59,11 +72,56 @@ export class Rect {
* @returns Whether this rectangle intersects the provided rectangle.
*/
intersects(other: Rect): boolean {
return !(
this.left > other.right ||
this.right < other.left ||
this.top > other.bottom ||
this.bottom < other.top
// The following logic can be derived and then simplified from a longer form symmetrical check
// of verifying each rectangle's borders with the other rectangle by checking if either end of
// the border's line segment is contained within the other rectangle. The simplified version
// used here can be logically interpreted as ensuring that each line segment of 'this' rectangle
// is not outside the bounds of the 'other' rectangle (proving there's an intersection).
return (
this.left <= other.right &&
this.right >= other.left &&
this.bottom >= other.top &&
this.top <= other.bottom
);
}
/**
* Compares bounding rectangles for equality.
*
* @param a A Rect.
* @param b A Rect.
* @returns True iff the bounding rectangles are equal, or if both are null.
*/
static equals(a?: Rect | null, b?: Rect | null): boolean {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.top === b.top &&
a.bottom === b.bottom &&
a.left === b.left &&
a.right === b.right
);
}
/**
* Creates a new Rect using a position and supplied dimensions.
*
* @param position The upper left coordinate of the new rectangle.
* @param width The width of the rectangle, in pixels.
* @param height The height of the rectangle, in pixels.
* @returns A newly created Rect using the provided Coordinate and dimensions.
*/
static createFromPoint(
position: Coordinate,
width: number,
height: number,
): Rect {
const left = position.x;
const top = position.y;
return new Rect(top, top + height, left, left + width);
}
}

View File

@@ -63,6 +63,7 @@ import type {Trashcan} from './trashcan.js';
import * as arrayUtils from './utils/array.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as drag from './utils/drag.js';
import type {Metrics} from './utils/metrics.js';
import {Rect} from './utils/rect.js';
import {Size} from './utils/size.js';
@@ -181,9 +182,6 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
/** Vertical scroll value when scrolling started in pixel units. */
startScrollY = 0;
/** Distance from mouse to object being dragged. */
private dragDeltaXY: Coordinate | null = null;
/** Current scale. */
scale = 1;
@@ -1447,16 +1445,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
* @param xy Starting location of object.
*/
startDrag(e: PointerEvent, xy: Coordinate) {
// Record the starting offset between the bubble's location and the mouse.
const point = browserEvents.mouseToSvg(
e,
this.getParentSvg(),
this.getInverseScreenCTM(),
);
// Fix scale of mouse event.
point.x /= this.scale;
point.y /= this.scale;
this.dragDeltaXY = Coordinate.difference(xy, point);
drag.start(this, e, xy);
}
/**
@@ -1466,15 +1455,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
* @returns New location of object.
*/
moveDrag(e: PointerEvent): Coordinate {
const point = browserEvents.mouseToSvg(
e,
this.getParentSvg(),
this.getInverseScreenCTM(),
);
// Fix scale of mouse event.
point.x /= this.scale;
point.y /= this.scale;
return Coordinate.sum(this.dragDeltaXY!, point);
return drag.move(this, e);
}
/**
@@ -1645,23 +1626,56 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
return boundary;
}
/** Clean up the workspace by ordering all the blocks in a column. */
/** Clean up the workspace by ordering all the blocks in a column such that none overlap. */
cleanUp() {
this.setResizesEnabled(false);
eventUtils.setGroup(true);
const topBlocks = this.getTopBlocks(true);
let cursorY = 0;
for (let i = 0, block; (block = topBlocks[i]); i++) {
if (!block.isMovable()) {
continue;
const movableBlocks = topBlocks.filter((block) => block.isMovable());
const immovableBlocks = topBlocks.filter((block) => !block.isMovable());
const immovableBlockBounds = immovableBlocks.map((block) =>
block.getBoundingRectangle(),
);
const getNextIntersectingImmovableBlock = function (
rect: Rect,
): Rect | null {
for (const immovableRect of immovableBlockBounds) {
if (rect.intersects(immovableRect)) {
return immovableRect;
}
}
const xy = block.getRelativeToSurfaceXY();
block.moveBy(-xy.x, cursorY - xy.y, ['cleanup']);
return null;
};
let cursorY = 0;
const minBlockHeight = this.renderer.getConstants().MIN_BLOCK_HEIGHT;
for (const block of movableBlocks) {
// Make the initial movement of shifting the block to its best possible position.
let boundingRect = block.getBoundingRectangle();
block.moveBy(-boundingRect.left, cursorY - boundingRect.top, ['cleanup']);
block.snapToGrid();
boundingRect = block.getBoundingRectangle();
let conflictingRect = getNextIntersectingImmovableBlock(boundingRect);
while (conflictingRect != null) {
// If the block intersects with an immovable block, move it down past that immovable block.
cursorY =
conflictingRect.top + conflictingRect.getHeight() + minBlockHeight;
block.moveBy(0, cursorY - boundingRect.top, ['cleanup']);
block.snapToGrid();
boundingRect = block.getBoundingRectangle();
conflictingRect = getNextIntersectingImmovableBlock(boundingRect);
}
// Ensure all next blocks start past the most recent (which will also put them past all
// previously intersecting immovable blocks).
cursorY =
block.getRelativeToSurfaceXY().y +
block.getHeightWidth().height +
this.renderer.getConstants().MIN_BLOCK_HEIGHT;
minBlockHeight;
}
eventUtils.setGroup(false);
this.setResizesEnabled(true);

View File

@@ -109,9 +109,9 @@ BlockLibraryController.prototype.clearBlockLibrary = function() {
BlockLibraryController.prototype.saveToBlockLibrary = function() {
var blockType = this.getCurrentBlockType();
// If user has not changed the name of the starter block.
if (blockType === 'block_type') {
if (reservedBlockFactoryBlocks.has(blockType) || blockType === 'block_type') {
// Do not save block if it has the default type, 'block_type'.
var msg = 'You cannot save a block under the name "block_type". Try ' +
var msg = `You cannot save a block under the name "${blockType}". Try ` +
'changing the name before saving. Then, click on the "Block Library"' +
' button to view your saved blocks.';
alert(msg);

View File

@@ -104,36 +104,36 @@ BlockLibraryView.prototype.updateButtons =
// User is editing a block.
if (!isInLibrary) {
// Block type has not been saved to library yet. Disable the delete button
// and allow user to save.
// Block type has not been saved to the library yet.
// Disable the delete button.
this.saveButton.textContent = 'Save "' + blockType + '"';
this.saveButton.disabled = false;
this.deleteButton.disabled = true;
} else {
// Block type has already been saved. Disable the save button unless the
// there are unsaved changes (checked below).
// A version of the block type has already been saved.
// Enable the delete button.
this.saveButton.textContent = 'Update "' + blockType + '"';
this.saveButton.disabled = true;
this.deleteButton.disabled = false;
}
this.deleteButton.textContent = 'Delete "' + blockType + '"';
// If changes to block have been made and are not saved, make button
// green to encourage user to save the block.
this.saveButton.classList.remove('button_alert', 'button_warn');
if (!savedChanges) {
var buttonFormatClass = 'button_warn';
var buttonFormatClass;
// If block type is the default, 'block_type', make button red to alert
// user.
if (blockType === 'block_type') {
var isReserved = reservedBlockFactoryBlocks.has(blockType);
if (isReserved || blockType === 'block_type') {
// Make button red to alert user that the block type can't be saved.
buttonFormatClass = 'button_alert';
} else {
// Block type has not been saved to library yet or has unsaved changes.
// Make the button green to encourage the user to save the block.
buttonFormatClass = 'button_warn';
}
this.saveButton.classList.add(buttonFormatClass);
this.saveButton.disabled = false;
} else {
// No changes to save.
this.saveButton.classList.remove('button_alert', 'button_warn');
this.saveButton.disabled = true;
}

View File

@@ -914,3 +914,7 @@ function inputNameCheck(referenceBlock) {
'There are ' + count + ' input blocks\n with this name.' : null;
referenceBlock.setWarningText(msg);
}
// Make a set of all of block types that are required for the block factory.
var reservedBlockFactoryBlocks =
new Set(Object.getOwnPropertyNames(Blockly.Blocks));

View File

@@ -187,8 +187,9 @@ BlockFactory.updatePreview = function() {
// Don't let the user create a block type that already exists,
// because it doesn't work.
var warnExistingBlock = function(blockType) {
if (blockType in Blockly.Blocks) {
var text = `You can't make a block called ${blockType} in this tool because that name already exists.`;
if (reservedBlockFactoryBlocks.has(blockType)) {
var text = `You can't make a block called ${blockType} in this tool ` +
`because that name is reserved.`;
FactoryUtils.getRootBlock(BlockFactory.mainWorkspace).setWarningText(text);
console.error(text);
return true;

906
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -111,11 +111,11 @@
"@typescript-eslint/parser": "^8.1.0",
"async-done": "^2.0.0",
"chai": "^5.1.1",
"concurrently": "^8.0.1",
"concurrently": "^9.0.1",
"eslint": "^8.4.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsdoc": "^48.0.2",
"eslint-plugin-jsdoc": "^50.4.3",
"glob": "^10.3.4",
"google-closure-compiler": "^20240317.0.0",
"gulp": "^5.0.0",
@@ -143,7 +143,7 @@
"yargs": "^17.2.1"
},
"dependencies": {
"jsdom": "25.0.0"
"jsdom": "25.0.1"
},
"engines": {
"node": ">=18"

View File

@@ -41,12 +41,12 @@ suite('Comments', function () {
});
function assertEditable(comment) {
assert.isNotOk(comment.textBubble);
assert.isOk(comment.textInputBubble);
assert.isTrue(comment.textInputBubble.isEditable());
}
function assertNotEditable(comment) {
assert.isNotOk(comment.textInputBubble);
assert.isOk(comment.textBubble);
assert.isOk(comment.textInputBubble);
assert.isFalse(comment.textInputBubble.isEditable());
}
test('Editable', async function () {
await this.comment.setBubbleVisible(true);

View File

@@ -123,7 +123,7 @@ suite('Context Menu Items', function () {
suite('Cleanup', function () {
setup(function () {
this.cleanupOption = this.registry.getItem('cleanWorkspace');
this.cleanupStub = sinon.stub(this.workspace, 'cleanUp');
this.cleanUpStub = sinon.stub(this.workspace, 'cleanUp');
});
test('Enabled when multiple blocks', function () {
@@ -153,9 +153,9 @@ suite('Context Menu Items', function () {
);
});
test('Calls workspace cleanup', function () {
test('Calls workspace cleanUp', function () {
this.cleanupOption.callback(this.scope);
sinon.assert.calledOnce(this.cleanupStub);
sinon.assert.calledOnce(this.cleanUpStub);
});
test('Has correct label', function () {

View File

@@ -110,6 +110,7 @@
import './old_workspace_comment_test.js';
import './procedure_map_test.js';
import './blocks/procedures_test.js';
import './rect_test.js';
import './registry_test.js';
import './render_management_test.js';
import './serializer_test.js';

View File

@@ -533,7 +533,7 @@ suite('JSO Serialization', function () {
},
'block': {
'type': 'text',
'id': 'id3',
'id': 'id4',
'fields': {
'TEXT': '',
},

1668
tests/mocha/rect_test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -406,6 +406,560 @@ suite('WorkspaceSvg', function () {
});
});
});
suite('cleanUp', function () {
test('empty workspace does not change', function () {
this.workspace.cleanUp();
const blocks = this.workspace.getTopBlocks(true);
assert.equal(blocks.length, 0, 'workspace is empty');
});
test('single block at (0, 0) does not change', function () {
const blockJson = {
'type': 'math_number',
'x': 0,
'y': 0,
'fields': {
'NUM': 123,
},
};
Blockly.serialization.blocks.append(blockJson, this.workspace);
this.workspace.cleanUp();
const blocks = this.workspace.getTopBlocks(true);
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(blocks.length, 1, 'workspace has one top-level block');
assert.deepEqual(
blocks[0].getRelativeToSurfaceXY(),
origin,
'block is at origin',
);
});
test('single block at (10, 15) is moved to (0, 0)', function () {
const blockJson = {
'type': 'math_number',
'x': 10,
'y': 15,
'fields': {
'NUM': 123,
},
};
Blockly.serialization.blocks.append(blockJson, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(topBlocks.length, 1, 'workspace has one top-level block');
assert.equal(allBlocks.length, 1, 'workspace has one block overall');
assert.deepEqual(
topBlocks[0].getRelativeToSurfaceXY(),
origin,
'block is at origin',
);
});
test('single block at (10, 15) with child is moved as unit to (0, 0)', function () {
const blockJson = {
'type': 'logic_negate',
'id': 'parent',
'x': 10,
'y': 15,
'inputs': {
'BOOL': {
'block': {
'type': 'logic_boolean',
'id': 'child',
'fields': {
'BOOL': 'TRUE',
},
},
},
},
};
Blockly.serialization.blocks.append(blockJson, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(topBlocks.length, 1, 'workspace has one top-level block');
assert.equal(allBlocks.length, 2, 'workspace has two blocks overall');
assert.deepEqual(
topBlocks[0].getRelativeToSurfaceXY(),
origin,
'block is at origin',
);
assert.notDeepEqual(
allBlocks[1].getRelativeToSurfaceXY(),
origin,
'child is not at origin',
);
});
test('two blocks first at (10, 15) second at (0, 0) do not switch places', function () {
const blockJson1 = {
'type': 'math_number',
'id': 'block1',
'x': 10,
'y': 15,
'fields': {
'NUM': 123,
},
};
const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 0, 'y': 0};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
this.workspace.cleanUp();
// block1 and block2 do not switch places since blocks are pre-sorted by their position before
// being tidied up, so the order they were added to the workspace doesn't matter.
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock2 = new Blockly.utils.Coordinate(0, 50);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
origin,
'block2 is at origin',
);
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
belowBlock2,
'block1 is below block2',
);
});
test('two overlapping blocks are moved to origin and below', function () {
const blockJson1 = {
'type': 'math_number',
'id': 'block1',
'x': 25,
'y': 15,
'fields': {
'NUM': 123,
},
};
const blockJson2 = {
...blockJson1,
'id': 'block2',
'x': 15.25,
'y': 20.25,
};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock1 = new Blockly.utils.Coordinate(0, 50);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
origin,
'block1 is at origin',
);
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
belowBlock1,
'block2 is below block1',
);
});
test('two overlapping blocks with snapping are moved to grid-aligned positions', function () {
const blockJson1 = {
'type': 'math_number',
'id': 'block1',
'x': 25,
'y': 15,
'fields': {
'NUM': 123,
},
};
const blockJson2 = {
...blockJson1,
'id': 'block2',
'x': 15.25,
'y': 20.25,
};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
this.workspace.getGrid().setSpacing(20);
this.workspace.getGrid().setSnapToGrid(true);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const snappedOffOrigin = new Blockly.utils.Coordinate(10, 10);
const belowBlock1 = new Blockly.utils.Coordinate(10, 70);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
snappedOffOrigin,
'block1 is near origin',
);
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
belowBlock1,
'block2 is below block1',
);
});
test('two overlapping blocks are moved to origin and below including children', function () {
const blockJson1 = {
'type': 'logic_negate',
'id': 'block1',
'x': 10,
'y': 15,
'inputs': {
'BOOL': {
'block': {
'type': 'logic_boolean',
'fields': {
'BOOL': 'TRUE',
},
},
},
},
};
const blockJson2 = {
...blockJson1,
'id': 'block2',
'x': 15.25,
'y': 20.25,
};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock1 = new Blockly.utils.Coordinate(0, 50);
const block1Pos = block1.getRelativeToSurfaceXY();
const block2Pos = block2.getRelativeToSurfaceXY();
const block1ChildPos = block1.getChildren()[0].getRelativeToSurfaceXY();
const block2ChildPos = block2.getChildren()[0].getRelativeToSurfaceXY();
assert.equal(topBlocks.length, 2, 'workspace has two top-level block2');
assert.equal(allBlocks.length, 4, 'workspace has four blocks overall');
assert.deepEqual(block1Pos, origin, 'block1 is at origin');
assert.deepEqual(block2Pos, belowBlock1, 'block2 is below block1');
assert.isAbove(
block1ChildPos.x,
block1Pos.x,
"block1's child is right of it",
);
assert.isBelow(
block1ChildPos.y,
block2Pos.y,
"block1's child is above block 2",
);
assert.isAbove(
block2ChildPos.x,
block2Pos.x,
"block2's child is right of it",
);
assert.isAbove(
block2ChildPos.y,
block1Pos.y,
"block2's child is below block 1",
);
});
test('two large overlapping blocks are moved to origin and below', function () {
const blockJson1 = {
'type': 'controls_repeat_ext',
'id': 'block1',
'x': 10,
'y': 20,
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'fields': {
'NUM': 10,
},
},
},
'DO': {
'block': {
'type': 'controls_if',
'inputs': {
'IF0': {
'block': {
'type': 'logic_boolean',
'fields': {
'BOOL': 'TRUE',
},
},
},
'DO0': {
'block': {
'type': 'text_print',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'fields': {
'TEXT': 'abc',
},
},
},
},
},
},
},
},
},
},
};
const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 20, 'y': 30};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1 = this.workspace.getBlockById('block1');
const block2 = this.workspace.getBlockById('block2');
const origin = new Blockly.utils.Coordinate(0, 0);
const belowBlock1 = new Blockly.utils.Coordinate(0, 144);
assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks');
assert.deepEqual(
block1.getRelativeToSurfaceXY(),
origin,
'block1 is at origin',
);
assert.deepEqual(
block2.getRelativeToSurfaceXY(),
belowBlock1,
'block2 is below block1',
);
});
test('five overlapping blocks are moved in-order as one column', function () {
const blockJson1 = {
'type': 'math_number',
'id': 'block1',
'x': 1,
'y': 2,
'fields': {
'NUM': 123,
},
};
const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 3, 'y': 4};
const blockJson3 = {...blockJson1, 'id': 'block3', 'x': 5, 'y': 6};
const blockJson4 = {...blockJson1, 'id': 'block4', 'x': 7, 'y': 8};
const blockJson5 = {...blockJson1, 'id': 'block5', 'x': 9, 'y': 10};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
Blockly.serialization.blocks.append(blockJson3, this.workspace);
Blockly.serialization.blocks.append(blockJson4, this.workspace);
Blockly.serialization.blocks.append(blockJson5, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1Pos = this.workspace
.getBlockById('block1')
.getRelativeToSurfaceXY();
const block2Pos = this.workspace
.getBlockById('block2')
.getRelativeToSurfaceXY();
const block3Pos = this.workspace
.getBlockById('block3')
.getRelativeToSurfaceXY();
const block4Pos = this.workspace
.getBlockById('block4')
.getRelativeToSurfaceXY();
const block5Pos = this.workspace
.getBlockById('block5')
.getRelativeToSurfaceXY();
const origin = new Blockly.utils.Coordinate(0, 0);
assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks');
assert.deepEqual(block1Pos, origin, 'block1 is at origin');
assert.equal(block2Pos.x, 0, 'block2.x is at 0');
assert.equal(block3Pos.x, 0, 'block3.x is at 0');
assert.equal(block4Pos.x, 0, 'block4.x is at 0');
assert.equal(block5Pos.x, 0, 'block5.x is at 0');
assert.isAbove(block2Pos.y, block1Pos.y, 'block2 is below block1');
assert.isAbove(block3Pos.y, block2Pos.y, 'block3 is below block2');
assert.isAbove(block4Pos.y, block3Pos.y, 'block4 is below block3');
assert.isAbove(block5Pos.y, block4Pos.y, 'block5 is below block4');
});
test('single immovable block at (10, 15) is not moved', function () {
const blockJson = {
'type': 'math_number',
'x': 10,
'y': 15,
'movable': false,
'fields': {
'NUM': 123,
},
};
Blockly.serialization.blocks.append(blockJson, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const allBlocks = this.workspace.getAllBlocks(false);
const origPos = new Blockly.utils.Coordinate(10, 15);
assert.equal(topBlocks.length, 1, 'workspace has one top-level block');
assert.equal(allBlocks.length, 1, 'workspace has one block overall');
assert.deepEqual(
topBlocks[0].getRelativeToSurfaceXY(),
origPos,
'block is at (10, 15)',
);
});
test('multiple block types immovable blocks are not moved', function () {
const smallBlockJson = {
'type': 'math_number',
'fields': {
'NUM': 123,
},
};
const largeBlockJson = {
'type': 'controls_repeat_ext',
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'fields': {
'NUM': 10,
},
},
},
'DO': {
'block': {
'type': 'controls_if',
'inputs': {
'IF0': {
'block': {
'type': 'logic_boolean',
'fields': {
'BOOL': 'TRUE',
},
},
},
'DO0': {
'block': {
'type': 'text_print',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'fields': {
'TEXT': 'abc',
},
},
},
},
},
},
},
},
},
},
};
// Block 1 overlaps block 2 (immovable) from above.
const blockJson1 = {...smallBlockJson, 'id': 'block1', 'x': 1, 'y': 2};
const blockJson2 = {
...smallBlockJson,
'id': 'block2',
'x': 10,
'y': 20,
'movable': false,
};
// Block 3 overlaps block 2 (immovable) from below.
const blockJson3 = {...smallBlockJson, 'id': 'block3', 'x': 2, 'y': 30};
const blockJson4 = {...largeBlockJson, 'id': 'block4', 'x': 3, 'y': 40};
// Block 5 (immovable) will end up overlapping with block 4 since it's large and will be
// moved.
const blockJson5 = {
...smallBlockJson,
'id': 'block5',
'x': 20,
'y': 200,
'movable': false,
};
Blockly.serialization.blocks.append(blockJson1, this.workspace);
Blockly.serialization.blocks.append(blockJson2, this.workspace);
Blockly.serialization.blocks.append(blockJson3, this.workspace);
Blockly.serialization.blocks.append(blockJson4, this.workspace);
Blockly.serialization.blocks.append(blockJson5, this.workspace);
this.workspace.cleanUp();
const topBlocks = this.workspace.getTopBlocks(true);
const block1Rect = this.workspace
.getBlockById('block1')
.getBoundingRectangle();
const block2Rect = this.workspace
.getBlockById('block2')
.getBoundingRectangle();
const block3Rect = this.workspace
.getBlockById('block3')
.getBoundingRectangle();
const block4Rect = this.workspace
.getBlockById('block4')
.getBoundingRectangle();
const block5Rect = this.workspace
.getBlockById('block5')
.getBoundingRectangle();
assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks');
// Check that immovable blocks haven't moved.
assert.equal(block2Rect.left, 10, 'block2.x is at 10');
assert.equal(block2Rect.top, 20, 'block2.y is at 20');
assert.equal(block5Rect.left, 20, 'block5.x is at 20');
assert.equal(block5Rect.top, 200, 'block5.y is at 200');
// Check that movable positions have correctly been left-aligned.
assert.equal(block1Rect.left, 0, 'block1.x is at 0');
assert.equal(block3Rect.left, 0, 'block3.x is at 0');
assert.equal(block4Rect.left, 0, 'block4.x is at 0');
// Block order should be: 2, 1, 3, 5, 4 since 2 and 5 are immovable.
assert.isAbove(block1Rect.top, block2Rect.top, 'block1 is below block2');
assert.isAbove(block3Rect.top, block1Rect.top, 'block3 is below block1');
assert.isAbove(block5Rect.top, block3Rect.top, 'block5 is below block3');
assert.isAbove(block4Rect.top, block5Rect.top, 'block4 is below block5');
// Ensure no blocks intersect (can check in order due to the position verification above).
assert.isFalse(
block2Rect.intersects(block1Rect),
'block2/block1 do not intersect',
);
assert.isFalse(
block1Rect.intersects(block3Rect),
'block1/block3 do not intersect',
);
assert.isFalse(
block3Rect.intersects(block5Rect),
'block3/block5 do not intersect',
);
assert.isFalse(
block5Rect.intersects(block4Rect),
'block5/block4 do not intersect',
);
});
});
suite('Workspace Base class', function () {
testAWorkspace();
});