scratch-blocks 2.0.0-spork.5 → 2.0.0-spork.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-blocks",
3
- "version": "2.0.0-spork.5",
3
+ "version": "2.0.0-spork.7",
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",
@@ -17,12 +17,17 @@
17
17
  "test": "echo \"Error: no test specified\" && exit 1",
18
18
  "test:lint": "eslint ."
19
19
  },
20
+ "dependencies": {
21
+ "@blockly/continuous-toolbox": "^7.0.1",
22
+ "@blockly/field-colour": "^6.0.3",
23
+ "blockly": "^12.3.0-beta.0"
24
+ },
20
25
  "devDependencies": {
21
- "@commitlint/cli": "^17.8.1",
22
- "@commitlint/config-conventional": "^17.8.1",
23
- "eslint": "^4.19.1",
26
+ "@commitlint/cli": "17.8.1",
27
+ "@commitlint/config-conventional": "17.8.1",
28
+ "eslint": "4.19.1",
24
29
  "husky": "9.1.6",
25
- "scratch-semantic-release-config": "1.0.16",
30
+ "scratch-semantic-release-config": "4.0.0",
26
31
  "semantic-release": "22.0.12",
27
32
  "source-map-loader": "^4.0.1",
28
33
  "ts-loader": "^9.5.1",
@@ -31,11 +36,6 @@
31
36
  "webpack-cli": "^4.10.0",
32
37
  "webpack-dev-server": "^4.11.1"
33
38
  },
34
- "dependencies": {
35
- "@blockly/continuous-toolbox": "^7.0.1",
36
- "@blockly/field-colour": "^6.0.3",
37
- "blockly": "12.3.0-beta.0"
38
- },
39
39
  "config": {
40
40
  "commitizen": {
41
41
  "path": "cz-conventional-changelog"
@@ -374,6 +374,18 @@ Blockly.Blocks["sensing_dayssince2000"] = {
374
374
  },
375
375
  };
376
376
 
377
+ Blockly.Blocks["sensing_online"] = {
378
+ /**
379
+ * Block to report whether or not the system is online
380
+ */
381
+ init: function(this: Blockly.Block) {
382
+ this.jsonInit({
383
+ message0: Blockly.Msg.SENSING_ONLINE,
384
+ extensions: ["colours_sensing", "output_boolean", "monitor_block"],
385
+ });
386
+ },
387
+ };
388
+
377
389
  Blockly.Blocks["sensing_username"] = {
378
390
  /**
379
391
  * Block to report user's username
package/src/constants.ts CHANGED
@@ -74,3 +74,12 @@ export { OUTPUT_SHAPE_ROUND };
74
74
  */
75
75
  const NEW_BROADCAST_MESSAGE_ID = "NEW_BROADCAST_MESSAGE_ID";
76
76
  export { NEW_BROADCAST_MESSAGE_ID };
77
+
78
+ /**
79
+ * Enum defining supported Scratch block themes.
80
+ * Scratch block themes can customize the shape of blocks independently of their color.
81
+ */
82
+ export enum ScratchBlocksTheme {
83
+ CLASSIC = "classic",
84
+ CAT_BLOCKS = "catblocks",
85
+ }
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import "./blocks/sound";
23
23
  import * as scratchBlocksUtils from "./scratch_blocks_utils";
24
24
  import * as ScratchVariables from "./variables";
25
25
  import "./css";
26
+ import "./renderer/cat/renderer";
26
27
  import "./renderer/renderer";
27
28
  import * as contextMenuItems from "./context_menu_items";
28
29
  import {
@@ -64,6 +65,7 @@ import { registerRecyclableBlockFlyoutInflater } from "./recyclable_block_flyout
64
65
  import { registerScratchBlockPaster } from "./scratch_block_paster";
65
66
  import { registerStatusIndicatorLabelFlyoutInflater } from "./status_indicator_label_flyout_inflater";
66
67
  import { registerScratchContinuousCategory } from "./scratch_continuous_category";
68
+ import { ScratchBlocksTheme } from "./constants";
67
69
 
68
70
  export * from "blockly/core";
69
71
  export * from "./block_reporting";
@@ -83,7 +85,22 @@ export {
83
85
  } from "./status_indicator_label";
84
86
  export * from "./xml";
85
87
 
86
- export function inject(container: Element, options: Blockly.BlocklyOptions) {
88
+ interface ScratchBlocksOptions extends Blockly.BlocklyOptions {
89
+ /**
90
+ * Scratch uses "theme" to talk about the shape of blocks. The Blockly concept of a theme affects CSS properties and
91
+ * aligns more closely with "color mode" in Scratch.
92
+ */
93
+ scratchTheme?: ScratchBlocksTheme;
94
+ }
95
+
96
+ function sanitizeTheme(theme?: ScratchBlocksTheme) {
97
+ if (theme === ScratchBlocksTheme.CAT_BLOCKS) {
98
+ return theme;
99
+ }
100
+ return ScratchBlocksTheme.CLASSIC;
101
+ }
102
+
103
+ export function inject(container: Element, options: ScratchBlocksOptions) {
87
104
  registerScratchFieldAngle();
88
105
  registerFieldColourSlider();
89
106
  registerScratchFieldDropdown();
@@ -99,8 +116,10 @@ export function inject(container: Element, options: Blockly.BlocklyOptions) {
99
116
  registerStatusIndicatorLabelFlyoutInflater();
100
117
  registerScratchContinuousCategory();
101
118
 
119
+ const scratchTheme = sanitizeTheme(options.scratchTheme);
120
+
102
121
  Object.assign(options, {
103
- renderer: "scratch",
122
+ renderer: `scratch_${scratchTheme}`,
104
123
  plugins: {
105
124
  toolbox: ScratchContinuousToolbox,
106
125
  flyoutsVerticalToolbox: CheckableContinuousFlyout,
@@ -5,13 +5,14 @@
5
5
  */
6
6
 
7
7
  import * as Blockly from "blockly/core";
8
+ import { ConstantProvider } from "./constants";
8
9
 
9
10
  export class BowlerHat extends Blockly.blockRendering.Hat {
10
- constructor(constants: Blockly.blockRendering.ConstantProvider) {
11
+ constructor(constants: ConstantProvider) {
11
12
  super(constants);
12
13
  // Calculated dynamically by computeBounds_().
13
14
  this.width = 0;
14
- this.height = 20;
15
+ this.height = constants.BOWLER_HAT_HEIGHT;
15
16
  this.ascenderHeight = this.height;
16
17
  }
17
18
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Scratch Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as Blockly from "blockly/core";
8
+
9
+ import { type ConstantProvider, type CatPathState, PathCapType, PathEarState } from "./constants";
10
+ import { type CatScratchRenderer } from "./renderer";
11
+ import { type RenderInfo } from "./render_info";
12
+
13
+ const Svg = Blockly.utils.Svg;
14
+
15
+ enum FacePart {
16
+ MOUTH,
17
+ EYE_1_OPEN,
18
+ EYE_2_OPEN,
19
+ EYE_1_CLOSED,
20
+ EYE_2_CLOSED,
21
+ EAR_1_INSIDE,
22
+ EAR_2_INSIDE,
23
+ }
24
+
25
+ const setVisibility = (element: SVGElement, visible: boolean) => {
26
+ if (visible) {
27
+ element.style.removeProperty("visibility");
28
+ } else {
29
+ element.style.setProperty("visibility", "hidden");
30
+ }
31
+ };
32
+
33
+ /**
34
+ * Manages the SVG elements for the cat face.
35
+ * This class holds the persistent SVG elements and manages events (blinking, etc.)
36
+ * Owned by the PathObject with similar lifetime.
37
+ */
38
+ export class CatFace {
39
+ faceGroup_: SVGElement;
40
+ parts_ = {} as Record<FacePart, SVGElement>;
41
+ pathEarState: CatPathState;
42
+ constants_: ConstantProvider;
43
+ renderer_: CatScratchRenderer;
44
+ block_: Blockly.BlockSvg;
45
+
46
+ constructor(info: RenderInfo) {
47
+ this.constants_ = info.constants_;
48
+ this.renderer_ = info.renderer_;
49
+ this.block_ = info.block_;
50
+ this.pathEarState = {
51
+ capType: info.isBowlerHatBlock() ? PathCapType.BOWLER : PathCapType.CAP,
52
+ ear1State: PathEarState.UP,
53
+ ear2State: PathEarState.UP,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Initializes the face SVG elements if they haven't been created yet.
59
+ */
60
+ init(parent: SVGElement) {
61
+ if (this.faceGroup_) return;
62
+ this.buildFaceGeometry_(parent);
63
+ this.setupBlinking_();
64
+ this.setupEarFlicks_();
65
+ }
66
+
67
+ /**
68
+ * Updates the transform of the entire face group.
69
+ */
70
+ setTransform(transform: string) {
71
+ if (this.faceGroup_) {
72
+ this.faceGroup_.setAttribute("transform", transform);
73
+ }
74
+ }
75
+
76
+ private setupBlinking_() {
77
+ const blinkDuration = 100;
78
+ let ignoreBlink = false;
79
+
80
+ // TODO: Would it be better to use CSS for this?
81
+ Blockly.browserEvents.bind(this.block_.pathObject.svgPath, "mouseenter", this, () => {
82
+ if (ignoreBlink) return;
83
+ ignoreBlink = true;
84
+ setVisibility(this.parts_[FacePart.EYE_1_OPEN], false);
85
+ setVisibility(this.parts_[FacePart.EYE_2_OPEN], false);
86
+ setVisibility(this.parts_[FacePart.EYE_1_CLOSED], true);
87
+ setVisibility(this.parts_[FacePart.EYE_2_CLOSED], true);
88
+ setTimeout(() => {
89
+ setVisibility(this.parts_[FacePart.EYE_1_OPEN], true);
90
+ setVisibility(this.parts_[FacePart.EYE_2_OPEN], true);
91
+ setVisibility(this.parts_[FacePart.EYE_1_CLOSED], false);
92
+ setVisibility(this.parts_[FacePart.EYE_2_CLOSED], false);
93
+ }, blinkDuration);
94
+ setTimeout(() => {
95
+ ignoreBlink = false;
96
+ }, 2 * blinkDuration);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Asks the renderer to re-render the block at a time when it normally wouldn't.
102
+ * Necessary if the path geometry has changed (ear flicks).
103
+ * Not necessary for face changes (blinking).
104
+ */
105
+ private triggerRedraw() {
106
+ this.renderer_.render(this.block_);
107
+ }
108
+
109
+ private setupEarFlicks_() {
110
+ const flickDuration = 50;
111
+ let ignoreFlick1 = false;
112
+ let ignoreFlick2 = false;
113
+
114
+ Blockly.browserEvents.bind(this.parts_[FacePart.EAR_1_INSIDE], "mouseenter", this, () => {
115
+ if (ignoreFlick1) return;
116
+ ignoreFlick1 = true;
117
+ setVisibility(this.parts_[FacePart.EAR_1_INSIDE], false);
118
+ this.pathEarState.ear1State = PathEarState.DOWN;
119
+ this.triggerRedraw();
120
+ setTimeout(() => {
121
+ setVisibility(this.parts_[FacePart.EAR_1_INSIDE], true);
122
+ this.pathEarState.ear1State = PathEarState.UP;
123
+ this.triggerRedraw();
124
+ }, flickDuration);
125
+ setTimeout(() => {
126
+ ignoreFlick1 = false;
127
+ }, 2 * flickDuration);
128
+ });
129
+ Blockly.browserEvents.bind(this.parts_[FacePart.EAR_2_INSIDE], "mouseenter", this, () => {
130
+ if (ignoreFlick2) return;
131
+ ignoreFlick2 = true;
132
+ setVisibility(this.parts_[FacePart.EAR_2_INSIDE], false);
133
+ this.pathEarState.ear2State = PathEarState.DOWN;
134
+ this.triggerRedraw();
135
+ setTimeout(() => {
136
+ setVisibility(this.parts_[FacePart.EAR_2_INSIDE], true);
137
+ this.pathEarState.ear2State = PathEarState.UP;
138
+ this.triggerRedraw();
139
+ }, flickDuration);
140
+ setTimeout(() => {
141
+ ignoreFlick2 = false;
142
+ }, 2 * flickDuration);
143
+ });
144
+ }
145
+
146
+ private buildFaceGeometry_(parent: SVGElement) {
147
+ const face = Blockly.utils.dom.createSvgElement(
148
+ Svg.G,
149
+ {
150
+ fill: "#000000",
151
+ // transform set in setTransform()
152
+ },
153
+ parent
154
+ );
155
+ this.faceGroup_ = face;
156
+
157
+ this.parts_[FacePart.MOUTH] = Blockly.utils.dom.createSvgElement(
158
+ Svg.PATH,
159
+ {
160
+ "fill-opacity": this.constants_.FACE_OPACITY,
161
+ d: this.constants_.MOUTH_PATH,
162
+ },
163
+ face
164
+ );
165
+
166
+ this.parts_[FacePart.EAR_1_INSIDE] = Blockly.utils.dom.createSvgElement(
167
+ Svg.PATH,
168
+ {
169
+ fill: this.constants_.EAR_INSIDE_COLOR,
170
+ d: this.constants_.EAR_1_INSIDE_PATH,
171
+ },
172
+ face
173
+ );
174
+
175
+ this.parts_[FacePart.EAR_2_INSIDE] = Blockly.utils.dom.createSvgElement(
176
+ Svg.PATH,
177
+ {
178
+ fill: this.constants_.EAR_INSIDE_COLOR,
179
+ d: this.constants_.EAR_2_INSIDE_PATH,
180
+ },
181
+ face
182
+ );
183
+
184
+ this.parts_[FacePart.EYE_1_OPEN] = Blockly.utils.dom.createSvgElement(
185
+ Svg.CIRCLE,
186
+ {
187
+ "fill-opacity": this.constants_.FACE_OPACITY,
188
+ cx: this.constants_.EYE_1_X,
189
+ cy: this.constants_.EYE_1_Y,
190
+ r: this.constants_.OPEN_EYE_RADIUS,
191
+ },
192
+ face
193
+ );
194
+
195
+ this.parts_[FacePart.EYE_1_CLOSED] = Blockly.utils.dom.createSvgElement(
196
+ Svg.PATH,
197
+ {
198
+ "fill-opacity": this.constants_.FACE_OPACITY,
199
+ d: this.constants_.CLOSED_EYE_1_PATH,
200
+ },
201
+ face
202
+ );
203
+ setVisibility(this.parts_[FacePart.EYE_1_CLOSED], false);
204
+
205
+ this.parts_[FacePart.EYE_2_OPEN] = Blockly.utils.dom.createSvgElement(
206
+ Svg.CIRCLE,
207
+ {
208
+ "fill-opacity": this.constants_.FACE_OPACITY,
209
+ cx: this.constants_.EYE_2_X,
210
+ cy: this.constants_.EYE_2_Y,
211
+ r: this.constants_.OPEN_EYE_RADIUS,
212
+ },
213
+ face
214
+ );
215
+
216
+ this.parts_[FacePart.EYE_2_CLOSED] = Blockly.utils.dom.createSvgElement(
217
+ Svg.PATH,
218
+ {
219
+ "fill-opacity": this.constants_.FACE_OPACITY,
220
+ d: this.constants_.CLOSED_EYE_2_PATH,
221
+ },
222
+ face
223
+ );
224
+ setVisibility(this.parts_[FacePart.EYE_2_CLOSED], false);
225
+ }
226
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Scratch Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { ConstantProvider as ClassicConstantProvider } from "../constants";
8
+
9
+ export enum PathCapType {
10
+ CAP = "CAP",
11
+ BOWLER = "BOWLER",
12
+ }
13
+
14
+ export enum PathEarState {
15
+ DOWN = "DOWN",
16
+ UP = "UP",
17
+ }
18
+
19
+ export interface CatPathState {
20
+ capType: PathCapType;
21
+ ear1State: PathEarState; // Left ear in LTR, right in RTL
22
+ ear2State: PathEarState; // Right ear in LTR, left in RTL
23
+ }
24
+
25
+ export class ConstantProvider extends ClassicConstantProvider {
26
+ START_HAT_HEIGHT = 31.5;
27
+ START_HAT_WIDTH = 96;
28
+
29
+ BOWLER_HAT_HEIGHT = 35;
30
+
31
+ FACE_OPACITY = 0.6;
32
+
33
+ EYE_1_X = 59.2;
34
+ EYE_1_Y = -3.3;
35
+ EYE_2_X = 29.1;
36
+ EYE_2_Y = -3.3;
37
+ OPEN_EYE_RADIUS = 3.4;
38
+ CLOSED_EYE_1_PATH =
39
+ "M25.2-1.1c0.1,0,0.2,0,0.2,0l8.3-2.1l-7-4.8" +
40
+ "c-0.5-0.3-1.1-0.2-1.4,0.3s-0.2,1.1,0.3,1.4L29-4.1l-4,1" +
41
+ "c-0.5,0.1-0.9,0.7-0.7,1.2C24.3-1.4,24.7-1.1,25.2-1.1z";
42
+ CLOSED_EYE_2_PATH =
43
+ "M62.4-1.1c-0.1,0-0.2,0-0.2,0l-8.3-2.1l7-4.8" +
44
+ "c0.5-0.3,1.1-0.2,1.4,0.3s0.2,1.1-0.3,1.4l-3.4,2.3l4,1" +
45
+ "c0.5,0.1,0.9,0.7,0.7,1.2C63.2-1.4,62.8-1.1,62.4-1.1z";
46
+
47
+ MOUTH_PATH =
48
+ "M45.6,0.1c-0.9,0-1.7-0.3-2.3-0.9" +
49
+ "c-0.6,0.6-1.3,0.9-2.2,0.9c-0.9,0-1.8-0.3-2.3-0.9c-1-1.1-1.1-2.6-1.1-2.8" +
50
+ "c0-0.5,0.5-1,1-1l0,0c0.6,0,1,0.5,1,1c0,0.4,0.1,1.7,1.4,1.7" +
51
+ "c0.5,0,0.7-0.2,0.8-0.3c0.3-0.3,0.4-1,0.4-1.3c0-0.1,0-0.1,0-0.2" +
52
+ "c0-0.5,0.5-1,1-1l0,0c0.5,0,1,0.4,1,1c0,0,0,0.1,0,0.2" +
53
+ "c0,0.3,0.1,0.9,0.4,1.2C44.8-2.2,45-2,45.5-2s0.7-0.2,0.8-0.3" +
54
+ "c0.3-0.4,0.4-1.1,0.3-1.3c0-0.5,0.4-1,0.9-1.1c0.5,0,1,0.4,1.1,0.9" +
55
+ "c0,0.2,0.1,1.8-0.8,2.8C47.5-0.4,46.8,0.1,45.6,0.1z";
56
+
57
+ EAR_INSIDE_COLOR = "#FFD5E6";
58
+ EAR_1_INSIDE_PATH =
59
+ "M22.4-15.6c-1.7-4.2-4.5-9.1-5.8-8.5" +
60
+ "c-1.6,0.8-5.4,7.9-5,15.4c0,0.6,0.7,0.7,1.1,0.5c3-1.6,6.4-2.8,8.6-3.6" +
61
+ "C22.8-12.3,23.2-13.7,22.4-15.6z";
62
+ EAR_2_INSIDE_PATH =
63
+ "M73.1-15.6c1.7-4.2,4.5-9.1,5.8-8.5" +
64
+ "c1.6,0.8,5.4,7.9,5,15.4c0,0.6-0.7,0.7-1.1,0.5c-3-1.6-6.4-2.8-8.6-3.6" +
65
+ "C72.8-12.3,72.4-13.7,73.1-15.6z";
66
+
67
+ CAP_START_PATH = "c2.6,-2.3 5.5,-4.3 8.5,-6.2";
68
+ CAP_MIDDLE_PATH = "c8.4,-1.3 17,-1.3 25.4,0";
69
+ CAP_END_PATH = "c3,1.8 5.9,3.9 8.5,6.1";
70
+
71
+ CAP_EAR_1_UP_PATH =
72
+ "c-1,-12.5 5.3,-23.3 8.4,-24.8" + "c3.7,-1.8 16.5,13.1 18.4,15.4";
73
+ CAP_EAR_2_UP_PATH =
74
+ "c1.9,-2.3 14.7,-17.2 18.4,-15.4" + "c3.1,1.5 9.4,12.3 8.4,24.8";
75
+ CAP_EAR_1_DOWN_PATH =
76
+ "c-5.8,-4.8 -8,-18 -4.9,-19.5" + "c3.7,-1.8 24.5,11.1 31.7,10.1";
77
+ CAP_EAR_2_DOWN_PATH =
78
+ "c7.2,1 28,-11.9 31.7,-10.1" + "c3.1,1.5 0.9,14.7 -4.9,19.5";
79
+
80
+ BOWLER_START_PATH = ""; // opening curve depends on whether ear 1 is up or down
81
+ BOWLER_MIDDLE_PATH = "h33";
82
+ BOWLER_END_PATH = "a 20,20 0 0,1 20,20";
83
+ BOWLER_EAR_1_UP_PATH =
84
+ "c0,-7.1 3.7,-13.3 9.3,-16.9" +
85
+ "c1.7,-7.5 5.4,-13.2 7.6,-14.2" +
86
+ "c2.6,-1.3 10,6 14.6,11.1";
87
+ BOWLER_EAR_2_UP_PATH =
88
+ "c4.6,-5.1 11.9,-12.4 14.6,-11.1" +
89
+ "c1.9,0.9 4.9,5.2 6.8,11.1" +
90
+ "h7.8";
91
+ BOWLER_EAR_1_DOWN_PATH =
92
+ "c0,-4.6 1.6,-8.9 4.3,-12.3" +
93
+ "c-2.4,-5.6 -2.9,-12.4 -0.7,-13.4" +
94
+ "c2.1,-1 9.6,2.6 17,5.8" +
95
+ "h10.9";
96
+ BOWLER_EAR_2_DOWN_PATH =
97
+ "h11" +
98
+ "c7.4,-3.2 14.8,-6.8 16.9,-5.8" +
99
+ "c1.2,0.6 1.6,2.9 1.3,5.8";
100
+
101
+ // This number was determined experimentally:
102
+ // - The 17 came from zooming in on a "define" block and iterating to get a near-vertical edge.
103
+ // - The .7 came from measuring the width of the other parts of the SVG path.
104
+ BOWLER_WIDTH_MAGIC = 17.7;
105
+
106
+ /**
107
+ * Make the starting portion of a block's hat.
108
+ * The return value will be stored as START_HAT.
109
+ * In the case of cat blocks, this is just a placeholder for sizing.
110
+ */
111
+ override makeStartHat() {
112
+ return {
113
+ height: this.START_HAT_HEIGHT,
114
+ width: this.START_HAT_WIDTH,
115
+ path: this.makeCatPath(0, {
116
+ capType: PathCapType.CAP,
117
+ ear1State: PathEarState.UP,
118
+ ear2State: PathEarState.UP,
119
+ }),
120
+ };
121
+ }
122
+
123
+ makeCatPath(width: number, state: CatPathState) {
124
+ const pathStart = this[`${state.capType}_START_PATH`];
125
+ const pathEar1 =
126
+ this[`${state.capType}_EAR_1_${state.ear1State}_PATH`];
127
+ const pathMiddle = this[`${state.capType}_MIDDLE_PATH`];
128
+ const pathEar2 =
129
+ this[`${state.capType}_EAR_2_${state.ear2State}_PATH`];
130
+ const spacer = (state.capType === PathCapType.BOWLER)
131
+ ? `l ${width - this.START_HAT_WIDTH - this.BOWLER_WIDTH_MAGIC} 0`
132
+ : ""; // caps don't need an internal spacer like bowlers do
133
+ const pathEnd = this[`${state.capType}_END_PATH`];
134
+ return `${pathStart}${pathEar1}${pathMiddle}${pathEar2}${spacer}${pathEnd}`;
135
+ }
136
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Scratch Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Drawer as ClassicDrawer } from "../drawer";
8
+
9
+ import { type ConstantProvider } from "./constants";
10
+ import { type PathObject } from "./path_object";
11
+ import { type RenderInfo } from "./render_info";
12
+ import { CatFace } from "./cat_face";
13
+
14
+ export class Drawer extends ClassicDrawer {
15
+ constants_: ConstantProvider;
16
+ info_: RenderInfo;
17
+
18
+ override draw() {
19
+ // Make sure the face exists if we need it.
20
+ if (this.block_.hat) {
21
+ const pathObject = this.block_.pathObject as PathObject;
22
+
23
+ if (!pathObject.catFace) {
24
+ // Initialize the persistent face view.
25
+ // Be aware of lifetimes:
26
+ // Drawer and RenderInfo only exist during `Renderer.render(block)`.
27
+ // Block, PathObject, and CatFace last for the lifetime of the block.
28
+ // ConstantsProvider and the Renderer last for the lifetime of the workspace.
29
+ pathObject.catFace = new CatFace(this.info_);
30
+ pathObject.catFace.init(this.block_.getSvgRoot());
31
+ }
32
+ }
33
+
34
+ super.draw();
35
+ }
36
+
37
+ override drawInternals_() {
38
+ super.drawInternals_();
39
+
40
+ const pathObject = this.block_.pathObject as PathObject;
41
+ const catFace = pathObject.catFace;
42
+ if (catFace) {
43
+ // Update the transform for the whole group
44
+ const transformParts: string[] = [];
45
+ if (this.info_.RTL) {
46
+ transformParts.push("scale(-1 1)");
47
+ }
48
+ transformParts.push(`translate(0, ${this.info_.startY})`);
49
+ catFace.setTransform(transformParts.join(" "));
50
+ }
51
+ }
52
+
53
+ override makeReplacementTop_() {
54
+ if (!this.block_.hat) {
55
+ return super.makeReplacementTop_();
56
+ }
57
+ const pathObject = this.block_.pathObject as PathObject;
58
+ return this.constants_.makeCatPath(
59
+ this.info_.width,
60
+ pathObject.catFace.pathEarState
61
+ );
62
+ }
63
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Scratch Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { PathObject as ClassicPathObject } from "../path_object";
8
+ import { type CatFace } from "./cat_face";
9
+
10
+ export class PathObject extends ClassicPathObject {
11
+ /**
12
+ * The face view for this block.
13
+ * Only valid if this block has a hat and therefore should have a face.
14
+ */
15
+ catFace?: CatFace;
16
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Scratch Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { RenderInfo as ClassicRenderInfo } from "../render_info";
8
+
9
+ import { ConstantProvider } from "./constants";
10
+ import { CatScratchRenderer } from "./renderer";
11
+
12
+ export class RenderInfo extends ClassicRenderInfo {
13
+ override constants_: ConstantProvider;
14
+ override renderer_: CatScratchRenderer;
15
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Scratch Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as Blockly from "blockly/core";
8
+
9
+ import { ScratchRenderer } from "../renderer";
10
+
11
+ import { ConstantProvider } from "./constants";
12
+ import { Drawer } from "./drawer";
13
+ import { RenderInfo } from "./render_info";
14
+ import { PathObject } from "./path_object";
15
+
16
+ export class CatScratchRenderer extends ScratchRenderer {
17
+ override makeConstants_() {
18
+ return new ConstantProvider();
19
+ }
20
+
21
+ override makeDrawer_(block: Blockly.BlockSvg, info: RenderInfo) {
22
+ return new Drawer(block, info);
23
+ }
24
+
25
+ override makeRenderInfo_(block: Blockly.BlockSvg): RenderInfo {
26
+ return new RenderInfo(this, block);
27
+ }
28
+
29
+ override makePathObject(root: SVGElement, style: Blockly.Theme.BlockStyle): PathObject {
30
+ return new PathObject(root, style, this.getConstants());
31
+ }
32
+ }
33
+
34
+ Blockly.blockRendering.register("scratch_catblocks", CatScratchRenderer);
@@ -10,6 +10,8 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider {
10
10
  REPLACEMENT_GLOW_COLOUR = "#ffffff";
11
11
  SELECTED_GLOW_COLOUR = "#ffffff";
12
12
 
13
+ BOWLER_HAT_HEIGHT = 20;
14
+
13
15
  /**
14
16
  * Sets the visual theme used to render the workspace.
15
17
  * This method also synthesizes a "selected" theme, used to color blocks with
@@ -20,7 +22,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider {
20
22
  *
21
23
  * @param theme The new theme to apply.
22
24
  */
23
- setTheme(theme: Blockly.Theme) {
25
+ override setTheme(theme: Blockly.Theme) {
24
26
  const root = document.querySelector(":root") as HTMLElement;
25
27
  for (const [key, colour] of Object.entries(theme.blockStyles)) {
26
28
  if (typeof colour !== "object") {
@@ -52,8 +54,20 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider {
52
54
  super.setTheme(theme);
53
55
  }
54
56
 
55
- createDom(svg: SVGElement, tagName: string, selector: string) {
57
+ override createDom(svg: SVGElement, tagName: string, selector: string) {
56
58
  super.createDom(svg, tagName, selector);
57
59
  this.selectedGlowFilterId = "";
58
60
  }
61
+
62
+ /**
63
+ * Generate a bowler hat path string for a specific block.
64
+ * @param width the `info_.width` of the block.
65
+ * @returns The SVG path string for the bowler hat.
66
+ */
67
+ makeBowlerHatPath(width: number): string {
68
+ const bowlerHatPath = `a20,20 0 0,1 20,-20 l ${
69
+ width - 40
70
+ } 0 a20,20 0 0,1 20,20`;
71
+ return bowlerHatPath;
72
+ }
59
73
  }