Merge branch 'develop' into improve-workspace-svg-test-robustness

This commit is contained in:
Ben Henning
2025-01-16 22:16:01 +00:00
28 changed files with 537 additions and 378 deletions

View File

@@ -1046,22 +1046,19 @@ blocks['lists_split'] = {
/**
* Returns the state of this block as a JSON serializable object.
* This block does not need to serialize any specific state as it is already
* encoded in the dropdown values, but must have an implementation to avoid
* the backward compatible XML mutations being serialized.
*
* @returns The state of this block.
*/
saveExtraState: function (this: SplitBlock): null {
return null;
saveExtraState: function (this: SplitBlock): {mode: string} {
return {'mode': this.getFieldValue('MODE')};
},
/**
* Applies the given state to this block.
* No extra state is needed or expected as it is already encoded in the
* dropdown values.
*/
loadExtraState: function (this: SplitBlock) {},
loadExtraState: function (this: SplitBlock, state: {mode: string}) {
this.updateType_(state['mode']);
},
};
// Register provided blocks.

View File

@@ -243,7 +243,7 @@ export class BlockSvg
*
* @returns #RRGGBB string.
*/
getColourSecondary(): string | undefined {
getColourSecondary(): string {
return this.style.colourSecondary;
}
@@ -252,7 +252,7 @@ export class BlockSvg
*
* @returns #RRGGBB string.
*/
getColourTertiary(): string | undefined {
getColourTertiary(): string {
return this.style.colourTertiary;
}
@@ -1172,6 +1172,15 @@ export class BlockSvg
}
}
/**
* Returns the BlockStyle object used to style this block.
*
* @returns This block's style object.
*/
getStyle(): BlockStyle {
return this.style;
}
/**
* Move this block to the front of the visible workspace.
* <g> tags do not respect z-index so SVG renders them in the

View File

@@ -16,7 +16,7 @@ import {Bubble} from './bubble.js';
* A bubble that displays non-editable text. Used by the warning icon.
*/
export class TextBubble extends Bubble {
private paragraph: SVGTextElement;
private paragraph: SVGGElement;
constructor(
private text: string,
@@ -48,43 +48,52 @@ export class TextBubble extends Bubble {
*/
private stringToSvg(text: string, container: SVGGElement) {
const paragraph = this.createParagraph(container);
const spans = this.createSpans(paragraph, text);
const fragments = this.createTextFragments(paragraph, text);
if (this.workspace.RTL)
this.rightAlignSpans(paragraph.getBBox().width, spans);
this.rightAlignTextFragments(paragraph.getBBox().width, fragments);
return paragraph;
}
/** Creates the paragraph container for this bubble's view's spans. */
private createParagraph(container: SVGGElement): SVGTextElement {
/** Creates the paragraph container for this bubble's view's text fragments. */
private createParagraph(container: SVGGElement): SVGGElement {
return dom.createSvgElement(
Svg.TEXT,
Svg.G,
{
'class': 'blocklyText blocklyBubbleText blocklyNoPointerEvents',
'y': Bubble.BORDER_WIDTH,
'transform': `translate(0,${Bubble.BORDER_WIDTH})`,
'style': `direction: ${this.workspace.RTL ? 'rtl' : 'ltr'}`,
},
container,
);
}
/** Creates the spans visualizing the text of this bubble. */
private createSpans(parent: SVGTextElement, text: string): SVGTSpanElement[] {
/** Creates the text fragments visualizing the text of this bubble. */
private createTextFragments(
parent: SVGGElement,
text: string,
): SVGTextElement[] {
let lineNum = 1;
return text.split('\n').map((line) => {
const tspan = dom.createSvgElement(
Svg.TSPAN,
{'dy': '1em', 'x': Bubble.BORDER_WIDTH},
const fragment = dom.createSvgElement(
Svg.TEXT,
{'y': `${lineNum}em`, 'x': Bubble.BORDER_WIDTH},
parent,
);
const textNode = document.createTextNode(line);
tspan.appendChild(textNode);
return tspan;
fragment.appendChild(textNode);
lineNum += 1;
return fragment;
});
}
/** Right aligns the given spans. */
private rightAlignSpans(maxWidth: number, spans: SVGTSpanElement[]) {
for (const span of spans) {
span.setAttribute('text-anchor', 'end');
span.setAttribute('x', `${maxWidth + Bubble.BORDER_WIDTH}`);
/** Right aligns the given text fragments. */
private rightAlignTextFragments(
maxWidth: number,
fragments: SVGTextElement[],
) {
for (const text of fragments) {
text.setAttribute('text-anchor', 'start');
text.setAttribute('x', `${maxWidth + Bubble.BORDER_WIDTH}`);
}
}

View File

@@ -83,7 +83,9 @@ function pasteFromData<T extends ICopyData>(
workspace: WorkspaceSvg,
coordinate?: Coordinate,
): ICopyable<T> | null {
workspace = workspace.getRootWorkspace() ?? workspace;
workspace = workspace.isMutator
? workspace
: (workspace.getRootWorkspace() ?? workspace);
return (globalRegistry
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;

View File

@@ -611,7 +611,9 @@ export function registerCommentDuplicate() {
export function registerCommentCreate() {
const createOption: RegistryItem = {
displayText: () => Msg['ADD_COMMENT'],
preconditionFn: () => 'enabled',
preconditionFn: (scope: Scope) => {
return scope.workspace?.isMutator ? 'hidden' : 'enabled';
},
callback: (scope: Scope, e: PointerEvent) => {
const workspace = scope.workspace;
if (!workspace) return;

View File

@@ -29,7 +29,6 @@ import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import * as utilsString from './utils/string.js';
import * as style from './utils/style.js';
import {Svg} from './utils/svg.js';
/**
@@ -291,7 +290,7 @@ export class FieldDropdown extends Field<string> {
if (this.getConstants()!.FIELD_DROPDOWN_COLOURED_DIV) {
const primaryColour = block.getColour();
const borderColour = (this.sourceBlock_ as BlockSvg).style.colourTertiary;
const borderColour = (this.sourceBlock_ as BlockSvg).getColourTertiary();
dropDownDiv.setColour(primaryColour, borderColour);
}
@@ -304,11 +303,6 @@ export class FieldDropdown extends Field<string> {
if (this.selectedMenuItem) {
this.menu_!.setHighlighted(this.selectedMenuItem);
style.scrollIntoContainerView(
this.selectedMenuItem.getElement()!,
dropDownDiv.getContentDiv(),
true,
);
}
this.applyColour();
@@ -467,21 +461,21 @@ export class FieldDropdown extends Field<string> {
* Updates the dropdown arrow to match the colour/style of the block.
*/
override applyColour() {
const style = (this.sourceBlock_ as BlockSvg).style;
const sourceBlock = this.sourceBlock_ as BlockSvg;
if (this.borderRect_) {
this.borderRect_.setAttribute('stroke', style.colourTertiary);
this.borderRect_.setAttribute('stroke', sourceBlock.getColourTertiary());
if (this.menu_) {
this.borderRect_.setAttribute('fill', style.colourTertiary);
this.borderRect_.setAttribute('fill', sourceBlock.getColourTertiary());
} else {
this.borderRect_.setAttribute('fill', 'transparent');
}
}
// Update arrow's colour.
if (this.sourceBlock_ && this.arrow) {
if (this.sourceBlock_.isShadow()) {
this.arrow.style.fill = style.colourSecondary;
if (sourceBlock && this.arrow) {
if (sourceBlock.isShadow()) {
this.arrow.style.fill = sourceBlock.getColourSecondary();
} else {
this.arrow.style.fill = style.colourPrimary;
this.arrow.style.fill = sourceBlock.getColour();
}
}
}

View File

@@ -226,7 +226,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (!this.isFullBlockField() && this.borderRect_) {
this.borderRect_!.style.display = 'block';
this.borderRect_.setAttribute('stroke', block.style.colourTertiary);
this.borderRect_.setAttribute('stroke', block.getColourTertiary());
} else {
this.borderRect_!.style.display = 'none';
// In general, do *not* let fields control the color of blocks. Having the
@@ -429,8 +429,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
borderRadius = (bBox.bottom - bBox.top) / 2 + 'px';
// Pull stroke colour from the existing shadow block
const strokeColour = block.getParent()
? (block.getParent() as BlockSvg).style.colourTertiary
: (this.sourceBlock_ as BlockSvg).style.colourTertiary;
? (block.getParent() as BlockSvg).getColourTertiary()
: (this.sourceBlock_ as BlockSvg).getColourTertiary();
htmlInput.style.border = 1 * scale + 'px solid ' + strokeColour;
div!.style.borderRadius = borderRadius;
div!.style.transition = 'box-shadow 0.25s ease 0s';

View File

@@ -126,7 +126,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
override applyColour(): void {
super.applyColour();
const colour = (this.sourceBlock as BlockSvg).style.colourPrimary;
const colour = (this.sourceBlock as BlockSvg).getColour();
this.textInputBubble?.setColour(colour);
}

View File

@@ -136,7 +136,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
override applyColour(): void {
super.applyColour();
this.miniWorkspaceBubble?.setColour(this.sourceBlock.style.colourPrimary);
this.miniWorkspaceBubble?.setColour(this.sourceBlock.getColour());
this.miniWorkspaceBubble?.updateBlockStyles();
}

View File

@@ -108,7 +108,7 @@ export class WarningIcon extends Icon implements IHasBubble {
override applyColour(): void {
super.applyColour();
this.textBubble?.setColour(this.sourceBlock.style.colourPrimary);
this.textBubble?.setColour(this.sourceBlock.getColour());
}
override updateCollapsed(): void {

View File

@@ -77,6 +77,16 @@ export function inject(
});
browserEvents.conditionalBind(subContainer, 'keydown', null, onKeyDown);
browserEvents.conditionalBind(
dropDownDiv.getContentDiv(),
'keydown',
null,
onKeyDown,
);
const widgetContainer = WidgetDiv.getDiv();
if (widgetContainer) {
browserEvents.conditionalBind(widgetContainer, 'keydown', null, onKeyDown);
}
return workspace;
}

View File

@@ -260,10 +260,14 @@ export class Menu {
this.highlightedItem = item;
// Bring the highlighted item into view. This has no effect if the menu is
// not scrollable.
const el = this.getElement() as Element;
style.scrollIntoContainerView(item.getElement() as Element, el);
aria.setState(el, aria.State.ACTIVEDESCENDANT, item.getId());
const el = this.getElement();
if (el) {
aria.setState(el, aria.State.ACTIVEDESCENDANT, item.getId());
}
item.getElement()?.scrollIntoView({
block: 'nearest',
inline: 'start',
});
}
}

View File

@@ -68,7 +68,7 @@ export class PathObject extends BasePathObject {
// Set shadow stroke colour.
const parent = block.getParent();
if (block.isShadow() && parent) {
this.svgPath.setAttribute('stroke', parent.style.colourTertiary);
this.svgPath.setAttribute('stroke', parent.getColourTertiary());
}
// Apply colour to outlines.

View File

@@ -18,7 +18,7 @@ import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {Coordinate} from './utils/coordinate.js';
import {KeyCodes} from './utils/keycodes.js';
import {Rect} from './utils/rect.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import {WorkspaceSvg} from './workspace_svg.js';
/**
* Object holding the names of the default shortcut items.
@@ -131,7 +131,10 @@ export function registerCopy() {
const selected = common.getSelected();
if (!selected || !isCopyable(selected)) return false;
copyData = selected.toCopyData();
copyWorkspace = workspace;
copyWorkspace =
selected.workspace instanceof WorkspaceSvg
? selected.workspace
: workspace;
copyCoords = isDraggable(selected)
? selected.getRelativeToSurfaceXY()
: null;

View File

@@ -395,6 +395,8 @@ export class Trashcan
'transform',
'translate(' + this.left + ',' + this.top + ')',
);
this.flyout?.position();
}
/**

View File

@@ -7,6 +7,7 @@
// Former goog.module ID: Blockly.utils.style
import {Coordinate} from './coordinate.js';
import * as deprecation from './deprecation.js';
import {Rect} from './rect.js';
import {Size} from './size.js';
@@ -58,6 +59,7 @@ function getSizeInternal(element: Element): Size {
* @returns Object with width/height properties.
*/
function getSizeWithDisplay(element: Element): Size {
deprecation.warn(`Blockly.utils.style.getSizeWithDisplay()`, 'v11.2', 'v13');
const offsetWidth = (element as HTMLElement).offsetWidth;
const offsetHeight = (element as HTMLElement).offsetHeight;
return new Size(offsetWidth, offsetHeight);
@@ -130,6 +132,7 @@ export function getViewportPageOffset(): Coordinate {
* @returns The computed border widths.
*/
export function getBorderBox(element: Element): Rect {
deprecation.warn(`Blockly.utils.style.getBorderBox()`, 'v11.2', 'v13');
const left = parseFloat(getComputedStyle(element, 'borderLeftWidth'));
const right = parseFloat(getComputedStyle(element, 'borderRightWidth'));
const top = parseFloat(getComputedStyle(element, 'borderTopWidth'));
@@ -156,6 +159,12 @@ export function scrollIntoContainerView(
container: Element,
opt_center?: boolean,
) {
deprecation.warn(
`Blockly.utils.style.scrollIntoContainerView()`,
'v11.2',
'v13',
'the native Element.scrollIntoView()',
);
const offset = getContainerOffsetToScrollInto(element, container, opt_center);
container.scrollLeft = offset.x;
container.scrollTop = offset.y;
@@ -180,6 +189,11 @@ export function getContainerOffsetToScrollInto(
container: Element,
opt_center?: boolean,
): Coordinate {
deprecation.warn(
`Blockly.utils.style.getContainerOffsetToScrollInto()`,
'v11.2',
'v13',
);
// Absolute position of the element's border's top left corner.
const elementPos = getPageOffset(element);
// Absolute position of the container's border's top left corner.

615
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "blockly",
"version": "11.1.1",
"version": "11.2.1",
"description": "Blockly is a library for building visual programming editors.",
"keywords": [
"blockly"

View File

@@ -159,7 +159,7 @@ module.exports = require('./${bundle}');
* This task copies all the media/* files into the release directory.
*/
function packageMedia() {
return gulp.src('media/*')
return gulp.src('media/*', {encoding: false})
.pipe(gulp.dest(`${RELEASE_DIR}/media`));
};

View File

@@ -137,7 +137,7 @@ suite('Disabling', function () {
110,
);
await connect(this.browser, child, 'OUTPUT', parent, 'IF0');
await this.browser.pause(PAUSE_TIME);
await contextMenuSelect(this.browser, parent, 'Disable Block');
chai.assert.isTrue(await getIsDisabled(this.browser, child.id));

View File

@@ -123,10 +123,14 @@ suite('Delete blocks', function (done) {
)
.waitForExist({timeout: 2000, reverse: true});
// Load the start blocks
await this.browser.execute((blocks) => {
Blockly.serialization.workspaces.load(blocks, Blockly.getMainWorkspace());
}, startBlocks);
// Load the start blocks. This hangs indefinitely if `startBlocks` is
// passed without being stringified.
this.browser.execute((blocks) => {
Blockly.serialization.workspaces.load(
JSON.parse(blocks),
Blockly.getMainWorkspace(),
);
}, JSON.stringify(startBlocks));
// Wait for there to be a block on the main workspace before continuing
(await getBlockElementById(this.browser, firstBlockId)).waitForExist({
timeout: 2000,

View File

@@ -26,6 +26,9 @@ suite('Testing Connecting Blocks', function (done) {
// Setup Selenium for all of the tests
suiteSetup(async function () {
this.browser = await testSetup(testFileLocations.CODE_DEMO);
// Prevent WebDriver from suppressing alerts
// https://github.com/webdriverio/webdriverio/issues/13610#issuecomment-2357768103
this.browser.on('dialog', (dialog) => {});
});
test('Testing Procedure', async function () {

View File

@@ -38,6 +38,7 @@ export async function driverSetup() {
const options = {
capabilities: {
'browserName': 'chrome',
'unhandledPromptBehavior': 'ignore',
'goog:chromeOptions': {
args: ['--allow-file-access-from-files'],
},
@@ -254,9 +255,9 @@ export async function getCategory(browser, categoryName) {
export async function getNthBlockOfCategory(browser, categoryName, n) {
const category = await getCategory(browser, categoryName);
await category.click();
const block = await browser.$(
`.blocklyFlyout .blocklyBlockCanvas > g:nth-child(${3 + n * 2})`,
);
const block = (
await browser.$$(`.blocklyFlyout .blocklyBlockCanvas > .blocklyDraggable`)
)[n];
return block;
}

View File

@@ -5,7 +5,6 @@
*/
import * as chai from 'chai';
import * as sinon from 'sinon';
import {testFileLocations, testSetup} from './test_setup.mjs';
suite('Workspace comments', function () {
@@ -20,8 +19,6 @@ suite('Workspace comments', function () {
});
teardown(async function () {
sinon.restore();
await this.browser.execute(() => {
Blockly.getMainWorkspace().clear();
});

View File

@@ -38,7 +38,7 @@ function start() {
* If some tests are failing, load test suites individually to continue
* debugging.
*/
function loadSelected() {
async function loadSelected() {
var output = document.getElementById('importExport');
output.style.background = 'gray';
@@ -53,9 +53,12 @@ function loadSelected() {
if (boxList[i].checked) {
var testUrl = boxList[i].value;
if (testUrl) {
var xmlText = fetchFile(testUrl);
var xmlText = await fetchFile(testUrl);
if (xmlText !== null) {
fromXml(testUrl, xmlText, /* opt_append */ true);
// Clean up the workspace to normalize the position of blocks and
// thus the order of functions in the generated code.
Blockly.getMainWorkspace().cleanUp();
}
}
}
@@ -67,23 +70,24 @@ function loadSelected() {
/**
* Ask the user for a file name, then load that file's contents.
*/
function loadOther() {
async function loadOther() {
var url = window.prompt('Enter URL of test file.');
if (!url) {
return;
}
var xmlText = fetchFile(url);
var xmlText = await fetchFile(url);
if (xmlText !== null) {
fromXml(url, xmlText);
}
}
function fetchFile(xmlUrl) {
async function fetchFile(xmlUrl) {
try {
var xmlHttp = new XMLHttpRequest();
xmlHttp.open('GET', xmlUrl, false);
xmlHttp.setRequestHeader('Content-Type', 'text/xml');
xmlHttp.send('');
const response = await fetch(xmlUrl);
if (!response.ok) {
throw new Error(`Got a 404 when loading ${xmlUrl}`);
}
return response.text();
} catch (e) {
// Attempt to diagnose the problem.
var msg = 'Error: Unable to load XML data.\n';
@@ -95,7 +99,6 @@ function fetchFile(xmlUrl) {
alert(msg + '\n' + e);
return null;
}
return xmlHttp.responseText;
}
/**
@@ -188,7 +191,7 @@ function toDart() {
function changeIndex() {
var oneBasedIndex = document.getElementById('indexing').checked;
demoWorkspace.options.oneBasedIndex = oneBasedIndex;
demoWorkspace.getToolbox().flyout_.workspace_.options.oneBasedIndex = oneBasedIndex;
demoWorkspace.getToolbox().getFlyout().getWorkspace().options.oneBasedIndex = oneBasedIndex;
}
</script>

View File

@@ -78,7 +78,7 @@ async function runGeneratorsInBrowser(outputDir) {
await browser.execute(function() {
checkAll();
loadSelected();
return loadSelected();
});
await runLangGeneratorInBrowser(browser, prefix + '.js',

View File

@@ -181,16 +181,58 @@ suite('Lists', function () {
* Test cases for serialization tests.
* @type {Array<SerializationTestCase>}
*/
const testCases = makeTestCasesForBlockNotNeedingExtraState_(
const testCases = [
{
'type': 'lists_split',
'id': '1',
'fields': {
'MODE': 'SPLIT',
title: 'JSON for splitting',
json: {
type: 'lists_split',
id: '1',
extraState: {mode: 'SPLIT'},
fields: {MODE: 'SPLIT'},
inputs: {
DELIM: {
shadow: {
type: 'text',
id: '2',
fields: {
TEXT: ',',
},
},
},
},
},
assertBlockStructure: (block) => {
assert.equal(block.type, 'lists_split');
assert.deepEqual(block.outputConnection.getCheck(), ['Array']);
assert.isTrue(block.getField('MODE').getValue() === 'SPLIT');
},
},
'<mutation mode="SPLIT"></mutation>',
);
{
title: 'JSON for joining',
json: {
type: 'lists_split',
id: '1',
extraState: {mode: 'JOIN'},
fields: {MODE: 'JOIN'},
inputs: {
DELIM: {
shadow: {
type: 'text',
id: '2',
fields: {
TEXT: ',',
},
},
},
},
},
assertBlockStructure: (block) => {
assert.equal(block.type, 'lists_split');
assert.deepEqual(block.outputConnection.getCheck(), ['String']);
assert.isTrue(block.getField('MODE').getValue() === 'JOIN');
},
},
];
runSerializationTestSuite(testCases);
});
});

View File

@@ -61,6 +61,30 @@ suite('Clipboard', function () {
);
});
test('copied from a mutator pastes them into the mutator', async function () {
const block = Blockly.serialization.blocks.append(
{
'type': 'controls_if',
'id': 'blockId',
'extraState': {
'elseIfCount': 1,
},
},
this.workspace,
);
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
await mutatorIcon.setBubbleVisible(true);
const mutatorWorkspace = mutatorIcon.getWorkspace();
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
assert.notEqual(elseIf, undefined);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
const data = elseIf.toCopyData();
Blockly.clipboard.paste(data, mutatorWorkspace);
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
assert.lengthOf(this.workspace.getAllBlocks(), 1);
});
suite('pasted blocks are placed in unambiguous locations', function () {
test('pasted blocks are bumped to not overlap', function () {
const block = Blockly.serialization.blocks.append(