scratch-blocks 2.1.14 → 2.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"checkable_continuous_flyout.d.ts","sourceRoot":"","sources":["../../../src/checkable_continuous_flyout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,6BAA6B,CAAA;AACpF,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAiBvC,qBAAa,yBAA0B,SAAQ,gBAAgB;IAC7D,UAAkB,SAAS,EAAE,MAAM,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IAErB;;;OAGG;gBACS,gBAAgB,EAAE,OAAO,CAAC,OAAO;IAQ7C;;;;OAIG;IACH,SAAS,CAAC,cAAc,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ;IAUhD;;;;OAIG;IACH,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO;IAahD,cAAc;IAId,QAAQ;IAIR,SAAS,CAAC,eAAe;IAwBzB;;;;OAIG;IACH,SAAS,CAAC,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,UAAU,GAAG,IAAI,IAAI,eAAe;IAO/E;;OAEG;IACH,oBAAoB;IASpB,QAAQ,CAAC,QAAQ,EAAE,MAAM;CAG1B"}
1
+ {"version":3,"file":"checkable_continuous_flyout.d.ts","sourceRoot":"","sources":["../../../src/checkable_continuous_flyout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,6BAA6B,CAAA;AACpF,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAsCvC,qBAAa,yBAA0B,SAAQ,gBAAgB;IAC7D,UAAkB,SAAS,EAAE,MAAM,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IAErB;;;OAGG;gBACS,gBAAgB,EAAE,OAAO,CAAC,OAAO;IAQ7C;;;;OAIG;IACH,SAAS,CAAC,cAAc,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ;IAWhD;;;;OAIG;IACH,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO;IAahD,cAAc;IAId,QAAQ;IAIR,SAAS,CAAC,eAAe;IAwBzB;;;;OAIG;IACH,SAAS,CAAC,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,UAAU,GAAG,IAAI,IAAI,eAAe;IAO/E;;OAEG;IACH,oBAAoB;IASpB,QAAQ,CAAC,QAAQ,EAAE,MAAM;CAG1B"}
@@ -1 +1 @@
1
- {"version":3,"file":"xml.d.ts","sourceRoot":"","sources":["../../../src/xml.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,CAAC,YAAY,GAAG,MAAM,EAAE,CAkDpG"}
1
+ {"version":3,"file":"xml.d.ts","sourceRoot":"","sources":["../../../src/xml.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAwEvC;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,CAAC,YAAY,GAAG,MAAM,EAAE,CAkDpG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-blocks",
3
- "version": "2.1.14",
3
+ "version": "2.1.16",
4
4
  "description": "Scratch Blocks is a library for building creative computing interfaces.",
5
5
  "author": "Massachusetts Institute of Technology",
6
6
  "license": "Apache-2.0",
@@ -41,17 +41,17 @@
41
41
  "@vitest/browser": "4.1.4",
42
42
  "@vitest/browser-playwright": "4.1.4",
43
43
  "eslint": "9.39.4",
44
- "eslint-config-scratch": "14.1.8",
44
+ "eslint-config-scratch": "14.1.11",
45
45
  "husky": "9.1.7",
46
46
  "playwright": "1.59.1",
47
- "prettier": "3.8.2",
47
+ "prettier": "3.8.3",
48
48
  "scratch-semantic-release-config": "4.0.1",
49
49
  "semantic-release": "25.0.3",
50
50
  "source-map-loader": "5.0.0",
51
51
  "ts-loader": "9.5.7",
52
52
  "typescript": "5.9.3",
53
53
  "vitest": "4.1.4",
54
- "webpack": "5.106.1",
54
+ "webpack": "5.106.2",
55
55
  "webpack-cli": "6.0.1",
56
56
  "webpack-dev-server": "5.2.3"
57
57
  },
@@ -20,6 +20,27 @@ function isCheckboxIcon(icon: Blockly.IIcon | undefined): icon is Blockly.IIcon
20
20
  return !!icon && typeof (icon as { setChecked?: unknown }).setChecked === 'function'
21
21
  }
22
22
 
23
+ /**
24
+ * Recursively strip `id` properties from a serialized block state tree
25
+ * so that every block (including shadows and nested inputs) gets a fresh
26
+ * ID when deserialized onto the workspace.
27
+ * @param state A serialized block state object.
28
+ */
29
+ function stripIds(state: Blockly.serialization.blocks.State): void {
30
+ delete state.id
31
+ if (state.inputs) {
32
+ for (const inputName in state.inputs) {
33
+ const conn = state.inputs[inputName]
34
+ if (conn.shadow) stripIds(conn.shadow)
35
+ if (conn.block) stripIds(conn.block)
36
+ }
37
+ }
38
+ if (state.next) {
39
+ if (state.next.shadow) stripIds(state.next.shadow)
40
+ if (state.next.block) stripIds(state.next.block)
41
+ }
42
+ }
43
+
23
44
  export class CheckableContinuousFlyout extends ContinuousFlyout {
24
45
  declare protected tabWidth_: number
25
46
  declare MARGIN: number
@@ -44,11 +65,12 @@ export class CheckableContinuousFlyout extends ContinuousFlyout {
44
65
  */
45
66
  protected serializeBlock(block: Blockly.BlockSvg) {
46
67
  const json = super.serializeBlock(block)
47
- // Delete the serialized block's ID so that a new one is generated when it is
48
- // placed on the workspace. Otherwise, the block on the workspace may be
49
- // indistinguishable from the one in the flyout, which can cause reporter blocks
50
- // to have their value dropdown shown in the wrong place.
51
- delete json.id
68
+ // Strip all IDs so every block in the tree (including shadows) gets a
69
+ // fresh ID when placed on the workspace. Without this, disposed shadows
70
+ // from a previous copy can reuse the flyout's IDs, causing two workspace
71
+ // blocks to share the same shadow in the VM. Deleting one then destroys
72
+ // the other's shadow (bug 878291).
73
+ stripIds(json)
52
74
  return json
53
75
  }
54
76
 
@@ -81,9 +81,10 @@ Reflect.set(
81
81
  // Restore a real block from a statement input to nextConnection so that
82
82
  // unplug(true) can heal the stack correctly. Conditions:
83
83
  // - marker is mid-stack (has a predecessor)
84
- // - marker.nextConnection is empty (displaced block is NOT already there)
84
+ // - marker.nextConnection is either empty or absent (cap blocks do not
85
+ // have a bottom connector, so the displaced block is NOT already there)
85
86
  // - a non-marker block is sitting in a statement input
86
- if (markerPreviousConnection?.isConnected() && markerNextConnection && !markerNextConnection.isConnected()) {
87
+ if (markerPreviousConnection?.isConnected() && !markerNextConnection?.isConnected()) {
87
88
  for (const input of marker.inputList) {
88
89
  const conn = input.connection
89
90
  const connType = conn?.type as Blockly.ConnectionType | undefined
@@ -97,7 +98,19 @@ Reflect.set(
97
98
  continue
98
99
  }
99
100
  prev.disconnect()
100
- markerNextConnection.connect(prev)
101
+ if (markerNextConnection) {
102
+ markerNextConnection.connect(prev)
103
+ } else {
104
+ // Blocks without a bottom connector (e.g. forever) have no nextConnection. Reconnect
105
+ // the displaced block directly to the connection the marker's
106
+ // previousConnection is plugged into, then detach the marker
107
+ // so unplug() has nothing left to heal.
108
+ const aboveConn = markerPreviousConnection.targetConnection
109
+ if (aboveConn) {
110
+ markerPreviousConnection.disconnect()
111
+ aboveConn.connect(prev)
112
+ }
113
+ }
101
114
  break
102
115
  }
103
116
  }
@@ -44,6 +44,32 @@ class ScratchConnectionChecker extends Blockly.ConnectionChecker {
44
44
  return false
45
45
  }
46
46
 
47
+ // Blockly's base doDragChecks rejects inserting a block with no
48
+ // nextConnection into the middle of a stack (NEXT_STATEMENT case) because
49
+ // it assumes the displaced blocks have nowhere to go. Our
50
+ // getConnectionForOrphanedConnection patch routes displaced blocks into
51
+ // statement inputs, so we allow the connection when a suitable statement
52
+ // input exists on the dragging block.
53
+ if (
54
+ (b.type as Blockly.ConnectionType) === Blockly.ConnectionType.NEXT_STATEMENT &&
55
+ b.isConnected() &&
56
+ !(a.getSourceBlock() as Blockly.Block).nextConnection
57
+ ) {
58
+ const orphan = b.targetBlock()
59
+ const orphanPrev = (orphan as Blockly.Block | null)?.previousConnection
60
+ if (orphan && !orphan.isShadow() && orphanPrev) {
61
+ const canWrap = !!Blockly.Connection.getConnectionForOrphanedConnection(a.getSourceBlock(), orphanPrev)
62
+ if (canWrap) {
63
+ // Skip the base class NEXT_STATEMENT check (which would reject
64
+ // this) but still apply the other generic guards it uses.
65
+ if ('distanceFrom' in a && a.distanceFrom(b) > distance) return false
66
+ if (b.getSourceBlock().isInsertionMarker()) return false
67
+ if (Blockly.common.draggingConnections.includes(b)) return false
68
+ return true
69
+ }
70
+ }
71
+ }
72
+
47
73
  return super.doDragChecks(a, b, distance)
48
74
  }
49
75
  }
package/src/xml.ts CHANGED
@@ -5,6 +5,75 @@
5
5
  import * as Blockly from 'blockly/core'
6
6
  import { ScratchVariableModel } from './scratch_variable_model'
7
7
 
8
+ // Blockly's blockToDom has a bug where the `empty` flag for an input's
9
+ // container element is only set to false when a non-shadow target block
10
+ // exists. If a connection has shadow DOM but no target block (which can
11
+ // happen transiently during connect/disconnect operations or if shadow
12
+ // respawn fails), the shadow clone IS appended to the <value> container
13
+ // but the container is never appended to the output because `empty`
14
+ // stays true. This causes the input to silently disappear from the XML,
15
+ // which corrupts the VM's block representation on the next save/load.
16
+ //
17
+ // We fix this by wrapping blockToDom: after the original runs, we check
18
+ // each value/statement input. If the connection has shadow state but the
19
+ // output XML is missing the corresponding <value>/<statement> element,
20
+ // we serialize the shadow ourselves and inject it.
21
+ const originalBlockToDom = Blockly.Xml.blockToDom.bind(Blockly.Xml)
22
+
23
+ Blockly.Xml.blockToDom = function blockToDomFixed(
24
+ block: Blockly.Block,
25
+ opt_noId?: boolean,
26
+ ): Element | DocumentFragment {
27
+ const result = originalBlockToDom(block, opt_noId)
28
+ if (!(result instanceof Element)) return result
29
+
30
+ for (const input of block.inputList) {
31
+ if (!input.connection) continue
32
+
33
+ const name = input.name
34
+ // Check if this input already appears in the serialized XML.
35
+ // Use localName for case-insensitive comparison across DOM implementations.
36
+ const alreadySerialized = Array.from(result.children).some((child) => {
37
+ const tag = child.localName
38
+ return (tag === 'value' || tag === 'statement') && child.getAttribute('name') === name
39
+ })
40
+ if (alreadySerialized) continue
41
+
42
+ // The input is missing from the XML. Check if there's shadow state
43
+ // that should have been included.
44
+ if (!input.connection.getShadowState()) continue
45
+
46
+ // If shadow DOM is still available on the connection, clone that DOM
47
+ // into a new container and append it to the serialized output.
48
+ const existingShadowDom = input.connection.getShadowDom()
49
+ if (existingShadowDom) {
50
+ const tagName = input.type === Blockly.inputs.inputTypes.VALUE ? 'value' : 'statement'
51
+ const doc = result.ownerDocument
52
+ const container = doc.createElementNS(result.namespaceURI, tagName)
53
+ container.setAttribute('name', name)
54
+ const importedShadow = doc.importNode(existingShadowDom, true)
55
+ if (opt_noId) {
56
+ importedShadow.removeAttribute('id')
57
+ for (const el of importedShadow.querySelectorAll('[id]')) {
58
+ el.removeAttribute('id')
59
+ }
60
+ }
61
+ container.appendChild(importedShadow)
62
+ result.appendChild(container)
63
+ console.warn(
64
+ `[scratch-blocks] blockToDom fix: recovered missing input "${name}" on ${block.type} (${block.id})`,
65
+ )
66
+ } else {
67
+ console.warn(
68
+ `[scratch-blocks] blockToDom fix: input "${name}" on ${block.type} (${block.id}) has shadow state` +
69
+ ` but no shadow DOM — cannot recover`,
70
+ )
71
+ }
72
+ }
73
+
74
+ return result
75
+ }
76
+
8
77
  /**
9
78
  * Clears the workspace and loads the given serialized state.
10
79
  * @param xml XML representation of a Blockly workspace.