Skip to content

Commit 7cd4c5c

Browse files
authored
fix!: Use setShadowState instead of setShadow in shadow-block-converter
1 parent f37b209 commit 7cd4c5c

File tree

4 files changed

+364
-162
lines changed

4 files changed

+364
-162
lines changed

plugins/shadow-block-converter/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @blockly/shadow-block-converter [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly)
22

3-
A [Blockly](https://www.npmjs.com/package/blockly) plugin for automatically converting shadow blocks to real blocks when the user edits them.
3+
A [Blockly](https://www.npmjs.com/package/blockly) plugin for automatically converting shadow blocks to regular blocks when the user edits them.
44

55
## Installation
66

@@ -19,9 +19,24 @@ npm install @blockly/shadow-block-converter --save
1919
## Usage
2020

2121
This plugin exports a function called `shadowBlockConversionChangeListener`. If
22-
you add it as a change listener to your blockly workspace then any shadow block
23-
the user edits will be converted to a real block. See below for an example using
24-
it with a workspace.
22+
you add it as a change listener to your Blockly workspace then any shadow block
23+
the user edits will be converted to a regular block. This allows the user to
24+
move or delete the block, in which case the original shadow block will
25+
automatically return. With this plugin, shadow blocks behave like a persistent
26+
default value associated with the parent block (unlike standard Blockly
27+
behavior, where shadow blocks retain any edits made to them even after a regular
28+
block is dragged on top of them).
29+
30+
The regular block will be a new block instance, separate from the shadow block
31+
that was replaced, and will have a different id. It will otherwise have the same
32+
properties and shape as the original shadow block.
33+
34+
If the shadow block was attached to any ancestor blocks that were also shadows,
35+
they will be recreated as regular blocks. If the shadow block was attached to
36+
any descendent blocks, they will be recreated with different ids but will still
37+
be shadow blocks.
38+
39+
See below for an example using it with a workspace.
2540

2641
### JavaScript
2742

plugins/shadow-block-converter/src/shadow_block_converter.ts

Lines changed: 197 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -11,48 +11,56 @@
1111
import * as Blockly from 'blockly/core';
1212
import {Block} from 'blockly/core/block';
1313
import {Abstract} from 'blockly/core/events/events_abstract';
14-
import {BlockChangeJson} from 'blockly/core/events/events_block_change';
14+
15+
export interface BlockShadowStateChangeJson
16+
extends Blockly.Events.BlockBaseJson {
17+
inputIndexInParent: number | null;
18+
shadowState: Blockly.serialization.blocks.State;
19+
}
1520

1621
/**
17-
* A new blockly event class specifically for recording changes to the shadow
18-
* state of a block. This implementation is similar to and could be merged with
19-
* the implementation of Blockly.Events.BlockChange in Blockly core code.
22+
* A Blockly event class to revert a block connection's shadow state to the
23+
* provided state, to be used after attaching a child block that would
24+
* ordinarily overwrite the connection's shadow state.
2025
*/
21-
export class BlockShadowChange extends Blockly.Events.BlockBase {
26+
export class BlockShadowStateChange extends Blockly.Events.BlockBase {
2227
/**
2328
* The name of the event type for broadcast and listening purposes.
2429
*/
2530
/* eslint-disable @typescript-eslint/naming-convention */
26-
static readonly EVENT_TYPE = 'block_shadow_change';
31+
static readonly EVENT_TYPE = 'block_shadow_state_change';
2732
/* eslint-enable @typescript-eslint/naming-convention */
2833

2934
/**
30-
* The previous value of the field.
35+
* The index of the connection in the parent block's list of connections. If
36+
* null, then the nextConnection will be used instead.
3137
*/
32-
oldValue: unknown;
38+
inputIndexInParent: number | null;
3339

3440
/**
35-
* The new value of the field.
41+
* The intended shadow state of the connection.
3642
*/
37-
newValue: unknown;
43+
shadowState: Blockly.serialization.blocks.State;
3844

3945
/**
40-
* The constructor for a new BlockShadowChange event.
46+
* The constructor for a new BlockShadowStateChange event.
4147
*
42-
* @param block The changed block. Undefined for a blank event.
43-
* @param oldValue Previous value of shadow state.
44-
* @param newValue New value of shadow state.
48+
* @param block The parent of the connection to modify.
49+
* @param inputIndexInParent The index of the input associated with the
50+
* connection to modify, if it is associated with one. Otherwise the
51+
* nextConnection will be used.
52+
* @param shadowState The intended shadow state of the connection.
4553
*/
46-
constructor(block?: Block, oldValue?: boolean, newValue?: boolean) {
54+
constructor(
55+
block: Block,
56+
inputIndexInParent: number | null,
57+
shadowState: Blockly.serialization.blocks.State,
58+
) {
4759
super(block);
4860

49-
this.type = BlockShadowChange.EVENT_TYPE;
50-
51-
if (!block) {
52-
return; // Blank event to be populated by fromJson.
53-
}
54-
this.oldValue = typeof oldValue === 'undefined' ? '' : oldValue;
55-
this.newValue = typeof newValue === 'undefined' ? '' : newValue;
61+
this.type = BlockShadowStateChange.EVENT_TYPE;
62+
this.inputIndexInParent = inputIndexInParent;
63+
this.shadowState = shadowState;
5664
}
5765

5866
/**
@@ -61,10 +69,10 @@ export class BlockShadowChange extends Blockly.Events.BlockBase {
6169
* @returns JSON representation.
6270
* @override
6371
*/
64-
toJson(): BlockChangeJson {
65-
const json = super.toJson() as BlockChangeJson;
66-
json['oldValue'] = this.oldValue;
67-
json['newValue'] = this.newValue;
72+
toJson(): BlockShadowStateChangeJson {
73+
const json = super.toJson() as BlockShadowStateChangeJson;
74+
json['inputIndexInParent'] = this.inputIndexInParent;
75+
json['shadowState'] = this.shadowState;
6876
return json;
6977
}
7078

@@ -75,18 +83,18 @@ export class BlockShadowChange extends Blockly.Events.BlockBase {
7583
* @override
7684
*/
7785
static fromJson(
78-
json: BlockChangeJson,
86+
json: BlockShadowStateChangeJson,
7987
workspace: Blockly.Workspace,
8088
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
8189
event?: any,
82-
): BlockShadowChange {
90+
): BlockShadowStateChange {
8391
const newEvent = super.fromJson(
8492
json,
8593
workspace,
8694
event,
87-
) as BlockShadowChange;
88-
newEvent.oldValue = json['oldValue'];
89-
newEvent.newValue = json['newValue'];
95+
) as BlockShadowStateChange;
96+
newEvent.inputIndexInParent = json['inputIndexInParent'];
97+
newEvent.shadowState = json['shadowState'];
9098
return event;
9199
}
92100

@@ -97,7 +105,7 @@ export class BlockShadowChange extends Blockly.Events.BlockBase {
97105
* @override
98106
*/
99107
isNull(): boolean {
100-
return this.oldValue === this.newValue;
108+
return false;
101109
}
102110

103111
/**
@@ -122,17 +130,169 @@ export class BlockShadowChange extends Blockly.Events.BlockBase {
122130
);
123131
}
124132

125-
const value = forward ? this.newValue : this.oldValue;
126-
block.setShadow(!!value);
133+
const connections = block.getConnections_(true);
134+
135+
let connection: Blockly.Connection | null;
136+
if (this.inputIndexInParent === null) {
137+
connection = block.nextConnection;
138+
} else if (
139+
typeof this.inputIndexInParent !== 'number' ||
140+
this.inputIndexInParent < 0 ||
141+
this.inputIndexInParent >= connections.length
142+
) {
143+
throw new Error('inputIndexInParent was invalid.');
144+
} else {
145+
connection = block.inputList[this.inputIndexInParent].connection;
146+
}
147+
if (connection === null) {
148+
throw new Error('No matching connection was found.');
149+
}
150+
151+
if (forward) {
152+
connection.setShadowState(this.shadowState || null);
153+
}
154+
155+
// Nothing to be done when run backward, because removing a child block
156+
// doesn't overwrite the connection's shadowState and thus doesn't need to
157+
// be reverted.
127158
}
128159
}
129160

130161
Blockly.registry.register(
131162
Blockly.registry.Type.EVENT,
132-
BlockShadowChange.EVENT_TYPE,
133-
BlockShadowChange,
163+
BlockShadowStateChange.EVENT_TYPE,
164+
BlockShadowStateChange,
134165
);
135166

167+
/**
168+
* Convert the provided shadow block into a regular block, along with any parent
169+
* shadow blocks.
170+
*
171+
* The provided block will be deleted, and a new regular block will be created
172+
* in its place that has new id but is otherwise identical to the shadow block.
173+
* The parent connection's shadow state will be forcibly preserved, despite the
174+
* fact that attaching a regular block to the connection ordinarily overwrites
175+
* the connection's shadow state.
176+
*
177+
* @param shadowBlock
178+
* @returns The newly created regular block with a different id, if one could be
179+
* created.
180+
*/
181+
function reifyEditedShadowBlock(shadowBlock: Block): Blockly.Block {
182+
// Determine how the shadow block is connected to the parent.
183+
let parentConnection: Blockly.Connection | null = null;
184+
let connectionIsThroughOutputConnection = false;
185+
if (shadowBlock.previousConnection?.isConnected()) {
186+
parentConnection = shadowBlock.previousConnection.targetConnection;
187+
} else if (shadowBlock.outputConnection?.isConnected()) {
188+
parentConnection = shadowBlock.outputConnection.targetConnection;
189+
connectionIsThroughOutputConnection = true;
190+
}
191+
if (parentConnection === null) {
192+
// We can't change the shadow status of a block with no parent, so just
193+
// return the block as-is.
194+
return shadowBlock;
195+
}
196+
197+
// Get the parent block, and the index of the connection's input if it is
198+
// associated with one.
199+
let parentBlock = parentConnection.getSourceBlock();
200+
const inputInParent = parentConnection.getParentInput();
201+
const inputIndexInParent: number | null = inputInParent
202+
? parentBlock.inputList.indexOf(inputInParent)
203+
: null;
204+
205+
// Recover the state of the shadow block before it was edited. The connection
206+
// should still have the original state until a new block is attached to it.
207+
const originalShadowState = parentConnection.getShadowState(
208+
/* returnCurrent = */ false,
209+
);
210+
211+
// Serialize the current state of the shadow block (after it was edited).
212+
const editedBlockState = Blockly.serialization.blocks.save(shadowBlock, {
213+
addCoordinates: false,
214+
addInputBlocks: true,
215+
addNextBlocks: true,
216+
doFullSerialization: false,
217+
});
218+
if (originalShadowState === null || editedBlockState === null) {
219+
// The serialized block states are necessary to convert the block. Without
220+
// them, just return the block as-is.
221+
return shadowBlock;
222+
}
223+
224+
// If the parent block is a shadow, it must be converted first.
225+
if (parentBlock.isShadow()) {
226+
const newParentBlock = reifyEditedShadowBlock(parentBlock);
227+
if (newParentBlock === null) {
228+
throw new Error(
229+
"No parent block was created, so we can't recreate the " +
230+
'current block either.',
231+
);
232+
}
233+
parentBlock = newParentBlock;
234+
235+
// The reference to the connection is obsolete. Find it from the new parent.
236+
if (inputIndexInParent === null) {
237+
parentConnection = parentBlock.nextConnection;
238+
} else if (
239+
inputIndexInParent < 0 ||
240+
inputIndexInParent >= parentBlock.inputList.length
241+
) {
242+
throw new Error('inputIndexInParent is invalid.');
243+
} else {
244+
parentConnection = parentBlock.inputList[inputIndexInParent].connection;
245+
}
246+
if (parentConnection === null) {
247+
throw new Error(
248+
"Couldn't find the corresponding connection on the new " +
249+
'version of the parent block.',
250+
);
251+
}
252+
}
253+
254+
// Let Blockly generate a new id for the new regular block. Ideally, we would
255+
// let the shadow block and the regular block have the same id, and in
256+
// principle that ought to be possible since they don't need to coexist at the
257+
// same time. However, we'll need to call setShadowState on the connection
258+
// after attaching the regular block to revert any changes made by attaching
259+
// the block, and the setShadowState implementation temporarily instantiates
260+
// the provided shadow state, which can't have the same id as a block in the
261+
// workspace. The new shadow state id won't be compatible with any existing
262+
// undo history on the shadow block, such as the block change event that
263+
// triggered this whole shadow conversion!
264+
editedBlockState.id = undefined;
265+
266+
// Create a regular version of the shadow block by deserializing its state
267+
// independently from the connection.
268+
const regularBlock = Blockly.serialization.blocks.append(
269+
editedBlockState,
270+
parentBlock.workspace,
271+
{recordUndo: true},
272+
);
273+
274+
// Attach the regular block to the connection in place of the shadow block.
275+
const childConnection = connectionIsThroughOutputConnection
276+
? regularBlock.outputConnection
277+
: regularBlock.previousConnection;
278+
if (childConnection) {
279+
parentConnection.connect(childConnection);
280+
}
281+
282+
// The process of connecting a block overwrites the connection's shadow state,
283+
// so revert it.
284+
parentConnection.setShadowState(originalShadowState);
285+
Blockly.Events.fire(
286+
new BlockShadowStateChange(
287+
parentBlock,
288+
inputIndexInParent,
289+
originalShadowState,
290+
),
291+
);
292+
293+
return regularBlock;
294+
}
295+
136296
/**
137297
* Add this function to your workspace as a change listener to automatically
138298
* convert shadow blocks to real blocks whenever the user edits a field on the
@@ -191,40 +351,7 @@ export function shadowBlockConversionChangeListener(event: Abstract) {
191351
Blockly.Events.setGroup(true);
192352
}
193353

194-
// If the changed shadow block is a child of another shadow block, then both
195-
// blocks should be converted to real blocks. To find all the shadow block
196-
// ancestors that need to be converted to real blocks, seed the list of blocks
197-
// starting with the changed block, and append all shadow block ancestors.
198-
const shadowBlocks = [block];
199-
for (let i = 0; i < shadowBlocks.length; i++) {
200-
const shadowBlock = shadowBlocks[i];
201-
202-
// If connected blocks need to be converted too, add them to the list.
203-
const outputBlock: Block | null | undefined =
204-
shadowBlock.outputConnection?.targetBlock();
205-
const previousBlock: Block | null | undefined =
206-
shadowBlock.previousConnection?.targetBlock();
207-
if (outputBlock?.isShadow()) {
208-
shadowBlocks.push(outputBlock);
209-
}
210-
if (previousBlock?.isShadow()) {
211-
shadowBlocks.push(previousBlock);
212-
}
213-
}
214-
215-
// The list of shadow blocks starts with the deepest child and ends with the
216-
// highest parent, but the parent of a real block should never be a shadow
217-
// block, so the parents need to be converted to real blocks first. Start
218-
// at the end of the list and iterate backward to convert the blocks.
219-
for (let i = shadowBlocks.length - 1; i >= 0; i--) {
220-
const shadowBlock = shadowBlocks[i];
221-
// Convert the shadow block to a real block and fire an event recording the
222-
// change so that it can be undone. Ideally the
223-
// Blockly.Block.prototype.setShadow method should fire this event directly,
224-
// but for this plugin it needs to be explicitly fired here.
225-
shadowBlock.setShadow(false);
226-
Blockly.Events.fire(new BlockShadowChange(shadowBlock, true, false));
227-
}
354+
reifyEditedShadowBlock(block);
228355

229356
// Revert to the current event group, if any.
230357
Blockly.Events.setGroup(currentGroup);

plugins/shadow-block-converter/test/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ import {shadowBlockConversionChangeListener} from '../src/index';
1515
const toolbox: Blockly.utils.toolbox.ToolboxDefinition = {
1616
kind: 'flyoutToolbox',
1717
contents: [
18+
{
19+
kind: 'block',
20+
type: 'text_reverse',
21+
inputs: {
22+
TEXT: {
23+
shadow: {type: 'text', fields: {TEXT: 'abc'}},
24+
block: undefined,
25+
},
26+
},
27+
},
1828
{
1929
kind: 'block',
2030
type: 'colour_blend',

0 commit comments

Comments
 (0)