scratchblocks-plus 1.0.0

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.
Files changed (119) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +193 -0
  3. package/browser.es.js +8 -0
  4. package/browser.js +8 -0
  5. package/build/scratchblocks-plus.min.es.js +12 -0
  6. package/build/scratchblocks-plus.min.es.js.map +1 -0
  7. package/build/scratchblocks-plus.min.js +12 -0
  8. package/build/scratchblocks-plus.min.js.map +1 -0
  9. package/build/translations-all-es.js +11 -0
  10. package/build/translations-all-es.js.map +1 -0
  11. package/build/translations-all.js +11 -0
  12. package/build/translations-all.js.map +1 -0
  13. package/build/translations-es.js +11 -0
  14. package/build/translations-es.js.map +1 -0
  15. package/build/translations.js +11 -0
  16. package/build/translations.js.map +1 -0
  17. package/index.d.ts +297 -0
  18. package/index.js +229 -0
  19. package/locales/ab.json +1630 -0
  20. package/locales/af.json +1630 -0
  21. package/locales/all.d.ts +108 -0
  22. package/locales/all.js +161 -0
  23. package/locales/am.json +1925 -0
  24. package/locales/an.json +1630 -0
  25. package/locales/ar.json +1924 -0
  26. package/locales/ast.json +1630 -0
  27. package/locales/az.json +1925 -0
  28. package/locales/be.json +1630 -0
  29. package/locales/bg.json +1924 -0
  30. package/locales/bn.json +1630 -0
  31. package/locales/ca.json +1930 -0
  32. package/locales/ckb.json +1630 -0
  33. package/locales/cs.json +1930 -0
  34. package/locales/cy.json +1929 -0
  35. package/locales/da.json +1924 -0
  36. package/locales/de.json +1929 -0
  37. package/locales/el.json +1931 -0
  38. package/locales/eo.json +1630 -0
  39. package/locales/es-419.json +1924 -0
  40. package/locales/es.json +1929 -0
  41. package/locales/et.json +1924 -0
  42. package/locales/eu.json +1924 -0
  43. package/locales/fa.json +1929 -0
  44. package/locales/fi.json +1924 -0
  45. package/locales/fil.json +1631 -0
  46. package/locales/forums.js +37 -0
  47. package/locales/fr.json +1929 -0
  48. package/locales/fy.json +1630 -0
  49. package/locales/ga.json +1924 -0
  50. package/locales/gd.json +1929 -0
  51. package/locales/gl.json +1924 -0
  52. package/locales/ha.json +1630 -0
  53. package/locales/he.json +1929 -0
  54. package/locales/hi.json +1635 -0
  55. package/locales/hr.json +1929 -0
  56. package/locales/ht.json +1630 -0
  57. package/locales/hu.json +1930 -0
  58. package/locales/hy.json +1630 -0
  59. package/locales/id.json +1929 -0
  60. package/locales/is.json +1924 -0
  61. package/locales/it.json +1929 -0
  62. package/locales/ja-Hira.json +1637 -0
  63. package/locales/ja.json +1931 -0
  64. package/locales/ka.json +1630 -0
  65. package/locales/kk.json +1632 -0
  66. package/locales/km.json +1630 -0
  67. package/locales/ko.json +1924 -0
  68. package/locales/ku.json +1632 -0
  69. package/locales/lt.json +1924 -0
  70. package/locales/lv.json +1924 -0
  71. package/locales/mi.json +1924 -0
  72. package/locales/mn.json +1631 -0
  73. package/locales/nb.json +1929 -0
  74. package/locales/nl.json +1929 -0
  75. package/locales/nn.json +1630 -0
  76. package/locales/nso.json +1630 -0
  77. package/locales/oc.json +1630 -0
  78. package/locales/or.json +1631 -0
  79. package/locales/pl.json +1929 -0
  80. package/locales/pt-br.json +1924 -0
  81. package/locales/pt.json +1929 -0
  82. package/locales/qu.json +1630 -0
  83. package/locales/rap.json +1632 -0
  84. package/locales/ro.json +1929 -0
  85. package/locales/ru.json +1929 -0
  86. package/locales/sk.json +1924 -0
  87. package/locales/sl.json +1929 -0
  88. package/locales/sr.json +1924 -0
  89. package/locales/sv.json +1924 -0
  90. package/locales/sw.json +1630 -0
  91. package/locales/th.json +1924 -0
  92. package/locales/tn.json +1630 -0
  93. package/locales/tr.json +1932 -0
  94. package/locales/uk.json +1924 -0
  95. package/locales/uz.json +1631 -0
  96. package/locales/vi.json +1925 -0
  97. package/locales/xh.json +1630 -0
  98. package/locales/zh-cn.json +1930 -0
  99. package/locales/zh-tw.json +1930 -0
  100. package/locales/zu.json +1918 -0
  101. package/package.json +81 -0
  102. package/scratch2/blocks.js +1000 -0
  103. package/scratch2/draw.js +452 -0
  104. package/scratch2/filter.js +78 -0
  105. package/scratch2/index.js +12 -0
  106. package/scratch2/style.css.js +148 -0
  107. package/scratch2/style.js +214 -0
  108. package/scratch3/blocks.js +1134 -0
  109. package/scratch3/draw.js +334 -0
  110. package/scratch3/index.js +12 -0
  111. package/scratch3/style.css.js +280 -0
  112. package/scratch3/style.js +877 -0
  113. package/syntax/blocks.js +921 -0
  114. package/syntax/commands.js +1755 -0
  115. package/syntax/dropdowns.js +688 -0
  116. package/syntax/extensions.js +34 -0
  117. package/syntax/index.js +17 -0
  118. package/syntax/model.js +566 -0
  119. package/syntax/syntax.js +1091 -0
@@ -0,0 +1,1134 @@
1
+ import {
2
+ Label,
3
+ Icon,
4
+ Input,
5
+ Block,
6
+ Comment,
7
+ Glow,
8
+ Script,
9
+ Document,
10
+ extensions,
11
+ aliasExtensions,
12
+ } from "../syntax/index.js"
13
+
14
+ import SVG from "./draw.js"
15
+ import style from "./style.js"
16
+ const {
17
+ defaultFont,
18
+ commentFont,
19
+ makeStyle,
20
+ makeOriginalIcons,
21
+ makeHighContrastIcons,
22
+ iconName,
23
+ } = style
24
+
25
+ export class LabelView {
26
+ constructor(label) {
27
+ Object.assign(this, label)
28
+
29
+ this.el = null
30
+ this.height = 12
31
+ this.metrics = null
32
+ this.x = 0
33
+ }
34
+
35
+ get isLabel() {
36
+ return true
37
+ }
38
+
39
+ draw(_iconStyle) {
40
+ return this.el
41
+ }
42
+
43
+ get width() {
44
+ return this.metrics.width
45
+ }
46
+
47
+ measure() {
48
+ const value = this.value
49
+ const cls = `sb3-${this.cls}`
50
+ this.el = SVG.text(0, 13.1, value, {
51
+ class: `sb3-label ${cls}`,
52
+ })
53
+
54
+ let cache = LabelView.metricsCache[cls]
55
+ if (!cache) {
56
+ cache = LabelView.metricsCache[cls] = Object.create(null)
57
+ }
58
+
59
+ if (Object.hasOwnProperty.call(cache, value)) {
60
+ this.metrics = cache[value]
61
+ } else {
62
+ const font = /comment-label/.test(this.cls) ? commentFont : defaultFont
63
+ this.metrics = cache[value] = LabelView.measure(value, font)
64
+ // TODO: word-spacing? (fortunately it seems to have no effect!)
65
+ }
66
+ }
67
+
68
+ static measure(value, font) {
69
+ const context = LabelView.measuring
70
+ context.font = font
71
+ const textMetrics = context.measureText(value)
72
+ return { width: textMetrics.width }
73
+ }
74
+ }
75
+
76
+ LabelView.metricsCache = {}
77
+ LabelView.toMeasure = []
78
+
79
+ export class IconView {
80
+ constructor(icon) {
81
+ Object.assign(this, icon)
82
+
83
+ const info = IconView.icons[this.name]
84
+ if (!info) {
85
+ throw new Error(`no info for icon: ${this.name}`)
86
+ }
87
+ Object.assign(this, info)
88
+ }
89
+
90
+ get isIcon() {
91
+ return true
92
+ }
93
+
94
+ draw(iconStyle) {
95
+ return SVG.symbol(`#sb3-${iconName(this.name, iconStyle)}`, {
96
+ width: this.width,
97
+ height: this.height,
98
+ })
99
+ }
100
+
101
+ static get icons() {
102
+ return {
103
+ greenFlag: { width: 20, height: 21, dy: -1 },
104
+ stopSign: { width: 20, height: 20 },
105
+ turnLeft: { width: 24, height: 24 },
106
+ turnRight: { width: 24, height: 24 },
107
+ loopArrow: { width: 24, height: 24 },
108
+ addInput: { width: 4, height: 8 },
109
+ delInput: { width: 4, height: 8 },
110
+ list: { width: 15, height: 18 },
111
+ musicBlock: { width: 40, height: 40 },
112
+ penBlock: { width: 40, height: 40 },
113
+ videoBlock: { width: 40, height: 40, dy: 10 },
114
+ faceSensingBlock: { width: 40, height: 40, dy: 3.9932885906 }, // 40 - 21.46 * (40 / 23.84), expcept this is still slightly off?
115
+ ttsBlock: { width: 40, height: 40 },
116
+ translateBlock: { width: 40, height: 40 },
117
+ wedoBlock: { width: 40, height: 40 },
118
+ ev3Block: { width: 40, height: 40 },
119
+ microbitBlock: { width: 40, height: 40 },
120
+ makeymakeyBlock: { width: 40, height: 40 },
121
+ gdxforBlock: { width: 40, height: 40 },
122
+ boostBlock: { width: 40, height: 40 },
123
+ }
124
+ }
125
+ }
126
+
127
+ export class LineView {
128
+ constructor() {
129
+ this.width = 1
130
+ this.height = 40
131
+ this.x = 0
132
+ }
133
+
134
+ get isLine() {
135
+ return true
136
+ }
137
+
138
+ measure() {}
139
+
140
+ draw(_iconStyle, _parent) {
141
+ return SVG.el("line", {
142
+ class: `sb3-extension-line`,
143
+ "stroke-linecap": "round",
144
+ x1: 0,
145
+ y1: 0,
146
+ x2: 0,
147
+ y2: 40,
148
+ })
149
+ }
150
+ }
151
+
152
+ export class MatrixView {
153
+ constructor(matrix) {
154
+ Object.assign(this, matrix)
155
+ this.x = 0
156
+
157
+ if (this.rows && this.rows.length > 0) {
158
+ const numRows = this.rows.length
159
+ const numCols = this.rows[0].length
160
+
161
+ // Calculate cell size based on target height and number of rows
162
+ const cellSpacing = 1
163
+ const targetHeight = 26 // Target height for matrix display
164
+ const availableHeight = targetHeight - (numRows - 1) * cellSpacing
165
+ this.cellSize = Math.max(1, Math.floor(availableHeight / numRows))
166
+
167
+ // Calculate actual rendered dimensions
168
+ this.width = numCols * (this.cellSize + cellSpacing) - cellSpacing
169
+ this.height = numRows * (this.cellSize + cellSpacing) - cellSpacing
170
+ } else {
171
+ this.width = 0
172
+ this.height = 0
173
+ this.cellSize = 0
174
+ }
175
+ }
176
+
177
+ get isMatrix() {
178
+ return true
179
+ }
180
+
181
+ measure() {
182
+ // Already measured in constructor
183
+ }
184
+
185
+ draw(iconStyle, parent) {
186
+ if (!this.rows || this.rows.length === 0) {
187
+ return SVG.group([])
188
+ }
189
+
190
+ const cellSize = this.cellSize
191
+ const cellSpacing = 1
192
+ const totalCellSize = cellSize + cellSpacing
193
+ const elements = []
194
+
195
+ // Draw matrix cells
196
+ for (let rowIdx = 0; rowIdx < this.rows.length; rowIdx++) {
197
+ const row = this.rows[rowIdx]
198
+ for (let colIdx = 0; colIdx < row.length; colIdx++) {
199
+ const cell = row[colIdx]
200
+ const x = colIdx * totalCellSize
201
+ const y = rowIdx * totalCellSize
202
+
203
+ const isFilled = cell === true
204
+
205
+ // Use custom color or category-based styling
206
+ const rect = SVG.el("rect", {
207
+ x: x,
208
+ y: y,
209
+ width: cellSize,
210
+ height: cellSize,
211
+ rx: 1,
212
+ ry: 1,
213
+ "stroke-width": 0,
214
+ })
215
+
216
+ if (isFilled) {
217
+ rect.setAttribute("fill", "#FFFFFF")
218
+ } else {
219
+ rect.classList.add(`sb3-${parent.info.category}`)
220
+ }
221
+
222
+ elements.push(rect)
223
+ }
224
+ }
225
+
226
+ return SVG.group(elements)
227
+ }
228
+ }
229
+
230
+ export class InputView {
231
+ constructor(input) {
232
+ Object.assign(this, input)
233
+ if (input.label) {
234
+ this.label = newView(input.label)
235
+ }
236
+ // Create MatrixView if value is a Matrix
237
+ if (input.value && input.value.isMatrix) {
238
+ this.matrixView = new MatrixView(input.value)
239
+ }
240
+ this.isBoolean = this.shape === "boolean"
241
+ this.isDropdown = this.shape === "dropdown"
242
+ this.isRound = !(this.isBoolean || this.isDropdown)
243
+
244
+ this.x = 0
245
+ }
246
+
247
+ get isInput() {
248
+ return true
249
+ }
250
+
251
+ measure() {
252
+ if (this.hasLabel) {
253
+ this.label.measure()
254
+ }
255
+ if (this.matrixView) {
256
+ this.matrixView.measure()
257
+ }
258
+ }
259
+
260
+ static get shapes() {
261
+ return {
262
+ string: SVG.pillRect,
263
+ number: SVG.pillRect,
264
+ "number-dropdown": SVG.pillRect,
265
+ color: SVG.pillRect,
266
+ dropdown: SVG.roundRect,
267
+
268
+ boolean: SVG.pointedRect,
269
+ stack: SVG.stackRect,
270
+ reporter: SVG.pillRect,
271
+ }
272
+ }
273
+
274
+ draw(iconStyle, parent) {
275
+ let w
276
+ let label
277
+ let px
278
+
279
+ // Check if this has a matrix view
280
+ const hasMatrix = !!this.matrixView
281
+
282
+ if (hasMatrix) {
283
+ // Use same padding as text dropdowns for consistency
284
+ px = 11
285
+ const matrixWidth = this.matrixView.width
286
+ w = matrixWidth + px + 31 // Same calculation as text dropdown with arrow
287
+ this.height = 32 // Fixed height for consistency with other inputs
288
+ } else if (this.isBoolean) {
289
+ w = 48
290
+ } else if (this.isColor) {
291
+ w = 40
292
+ } else if (this.hasLabel) {
293
+ label = this.label.draw(iconStyle)
294
+ if (this.hasArrow) {
295
+ px = 11
296
+ w = this.label.width + px + 31
297
+ } else {
298
+ // Minimum padding of 11
299
+ // Minimum width of 40, at which point we center the label
300
+ px = this.label.width >= 18 ? 11 : (40 - this.label.width) / 2
301
+ w = this.label.width + 2 * px
302
+ }
303
+ label = SVG.move(px, 9, label)
304
+ } else {
305
+ w = this.isInset ? 30 : null
306
+ }
307
+ this.width = w
308
+
309
+ const h = this.height || 32
310
+ this.height = h
311
+
312
+ const el = InputView.shapes[this.shape](w, h)
313
+ SVG.setProps(el, {
314
+ class: `${
315
+ this.isColor ? "" : `sb3-${parent.info.category}`
316
+ } sb3-input sb3-input-${this.shape}`,
317
+ })
318
+
319
+ if (this.isColor) {
320
+ SVG.setProps(el, {
321
+ fill: this.value,
322
+ })
323
+ } else if (this.shape === "dropdown") {
324
+ // custom colors
325
+ if (parent.info.color) {
326
+ SVG.setProps(el, {
327
+ fill: parent.info.color,
328
+ stroke: "rgba(0, 0, 0, 0.2)",
329
+ })
330
+ }
331
+ } else if (this.shape === "number-dropdown") {
332
+ el.classList.add(`sb3-${parent.info.category}-alt`)
333
+
334
+ // custom colors
335
+ if (parent.info.color) {
336
+ SVG.setProps(el, {
337
+ fill: "rgba(0, 0, 0, 0.1)",
338
+ stroke: "rgba(0, 0, 0, 0.15)", // combines with fill...
339
+ })
340
+ }
341
+ } else if (this.shape === "boolean") {
342
+ el.classList.remove(`sb3-${parent.info.category}`)
343
+ el.classList.add(`sb3-${parent.info.category}-dark`)
344
+
345
+ // custom colors
346
+ if (parent.info.color) {
347
+ SVG.setProps(el, {
348
+ fill: "rgba(0, 0, 0, 0.15)",
349
+ })
350
+ }
351
+ }
352
+
353
+ const result = SVG.group([el])
354
+
355
+ // Render matrix content using MatrixView
356
+ if (hasMatrix) {
357
+ // Use same left margin as text dropdowns (px is already set to 11)
358
+ const matrixStartX = px
359
+ const matrixStartY = (h - this.matrixView.height) / 2
360
+
361
+ const matrixEl = this.matrixView.draw(iconStyle, parent)
362
+ result.appendChild(SVG.move(matrixStartX, matrixStartY, matrixEl))
363
+ }
364
+
365
+ if (this.hasLabel) {
366
+ result.appendChild(label)
367
+ }
368
+ if (this.hasArrow) {
369
+ result.appendChild(
370
+ SVG.move(
371
+ w - 24,
372
+ h === 32 ? 12.8505114083 : (h - 32) / 2 + 12.8505114083,
373
+ SVG.symbol(
374
+ iconStyle === "high-contrast"
375
+ ? "#sb3-dropdownArrow-high-contrast"
376
+ : "#sb3-dropdownArrow",
377
+ {},
378
+ ),
379
+ ),
380
+ )
381
+ }
382
+ return result
383
+ }
384
+ }
385
+
386
+ class BlockView {
387
+ constructor(block) {
388
+ Object.assign(this, block)
389
+ this.children = block.children.map(newView)
390
+ this.comment = this.comment ? newView(this.comment) : null
391
+ this.isRound = this.isReporter
392
+
393
+ // Store the original block for path reference
394
+ this.block = block
395
+
396
+ // Avoid accidental mutation
397
+ this.info = { ...block.info }
398
+ if (
399
+ Object.prototype.hasOwnProperty.call(aliasExtensions, this.info.category)
400
+ ) {
401
+ this.info.category = aliasExtensions[this.info.category]
402
+ }
403
+ if (Object.prototype.hasOwnProperty.call(extensions, this.info.category)) {
404
+ this.children.unshift(new LineView())
405
+ this.children.unshift(
406
+ new IconView({ name: this.info.category + "Block" }),
407
+ )
408
+ this.info.category = "extension"
409
+ }
410
+
411
+ this.x = 0
412
+ this.width = null
413
+ this.height = null
414
+ this.firstLine = null
415
+ this.innerWidth = null
416
+ }
417
+
418
+ get isBlock() {
419
+ return true
420
+ }
421
+
422
+ measure() {
423
+ for (const child of this.children) {
424
+ if (child.measure) {
425
+ child.measure()
426
+ }
427
+ }
428
+ if (this.comment) {
429
+ this.comment.measure()
430
+ }
431
+ }
432
+
433
+ static get shapes() {
434
+ return {
435
+ stack: SVG.stackRect,
436
+ "c-block": SVG.stackRect,
437
+ "if-block": SVG.stackRect,
438
+ celse: SVG.stackRect,
439
+ cend: SVG.stackRect,
440
+
441
+ cap: SVG.capRect,
442
+ reporter: SVG.pillRect,
443
+ boolean: SVG.pointedRect,
444
+ hat: SVG.hatRect,
445
+ cat: SVG.catHat,
446
+ "define-hat": SVG.procHatRect,
447
+ ring: SVG.pillRect,
448
+ }
449
+ }
450
+
451
+ drawSelf(iconStyle, w, h, lines) {
452
+ // mouths
453
+ if (lines.length > 1) {
454
+ return SVG.mouthRect(w, h, this.isFinal, lines, {
455
+ class: `sb3-${this.info.category}`,
456
+ })
457
+ }
458
+
459
+ // outlines
460
+ if (this.info.shape === "outline") {
461
+ return SVG.setProps(SVG.stackRect(w, h), {
462
+ class: `sb3-${this.info.category} sb3-${this.info.category}-alt`,
463
+ })
464
+ }
465
+
466
+ // rings
467
+ if (this.isRing) {
468
+ const child = this.children[0]
469
+ if (child && (child.isInput || child.isBlock || child.isScript)) {
470
+ return SVG.roundRect(w, h, {
471
+ class: `sb3-${this.info.category}`,
472
+ })
473
+ }
474
+ }
475
+
476
+ const func = BlockView.shapes[this.info.shape]
477
+ if (!func) {
478
+ throw new Error(`no shape func: ${this.info.shape}`)
479
+ }
480
+ return func(w, h, {
481
+ class: `sb3-${this.info.category}`,
482
+ })
483
+ }
484
+
485
+ static get padding() {
486
+ return {
487
+ hat: [24, 8],
488
+ cat: [24, 8],
489
+ "define-hat": [20, 16],
490
+ null: [4, 4],
491
+ }
492
+ }
493
+
494
+ horizontalPadding(child) {
495
+ if (this.isRound) {
496
+ if (child.isIcon) {
497
+ return 16
498
+ } else if (child.isLabel) {
499
+ return 12 // text in circle: 3 units
500
+ } else if (child.isDropdown) {
501
+ return 12 // square in circle: 3 units
502
+ } else if (child.isBoolean) {
503
+ return 12 // hexagon in circle: 3 units
504
+ } else if (child.isRound) {
505
+ return 4 // circle in circle: 1 unit
506
+ }
507
+ } else if (this.isBoolean) {
508
+ if (child.isIcon) {
509
+ return 24 // icon in hexagon: ???
510
+ } else if (child.isLabel) {
511
+ return 20 // text in hexagon: 5 units
512
+ } else if (child.isDropdown) {
513
+ return 20 // square in hexagon: 5 units
514
+ } else if (child.isRound && child.isBlock) {
515
+ return 24 // circle in hexagon: 5 + 1 units
516
+ } else if (child.isRound) {
517
+ return 20 // circle in hexagon: 5 units
518
+ } else if (child.isBoolean) {
519
+ return 8 // hexagon in hexagon: 2 units
520
+ }
521
+ }
522
+ return 8 // default: 2 units
523
+ }
524
+
525
+ marginBetween(a, b) {
526
+ // Consecutive labels should be rendered as a single text element.
527
+ // For now, manually offset by the size of one space
528
+ if (a.isLabel && b.isLabel) {
529
+ return 4.447998046875
530
+ }
531
+
532
+ return 8 // default: 2 units
533
+ }
534
+
535
+ draw(iconStyle) {
536
+ const isDefine = this.info.shape === "define-hat"
537
+ let children = this.children
538
+ const isCommand = this.isCommand
539
+
540
+ const padding = BlockView.padding[this.info.shape] || BlockView.padding.null
541
+ const pt = padding[0],
542
+ pb = padding[1]
543
+
544
+ let y = this.info.shape === "cat" ? 16 : 0
545
+ const Line = function (y) {
546
+ this.y = y
547
+ this.width = 0
548
+ this.height = isCommand ? 40 : 32
549
+ this.children = []
550
+ }
551
+
552
+ let innerWidth = 0
553
+ let scriptWidth = 0
554
+ let line = new Line(y)
555
+ const pushLine = () => {
556
+ if (lines.length === 0) {
557
+ line.height += pt + pb
558
+ } else {
559
+ line.height -= 11
560
+ line.y -= 2
561
+ }
562
+ y += line.height
563
+ lines.push(line)
564
+ }
565
+
566
+ if (this.info.isRTL) {
567
+ let start = 0
568
+ const flip = () => {
569
+ children = children
570
+ .slice(0, start)
571
+ .concat(children.slice(start, i).reverse())
572
+ .concat(children.slice(i))
573
+ }
574
+ let i
575
+ for (i = 0; i < children.length; i++) {
576
+ if (children[i].isScript) {
577
+ flip()
578
+ start = i + 1
579
+ }
580
+ }
581
+ if (start < i) {
582
+ flip()
583
+ }
584
+ }
585
+
586
+ const lines = []
587
+ let previousChild
588
+ let lastChild
589
+ for (let i = 0; i < children.length; i++) {
590
+ const child = children[i]
591
+ child.el = child.draw(iconStyle, this)
592
+
593
+ if (child.isScript && this.isCommand) {
594
+ this.hasScript = true
595
+ pushLine()
596
+ child.y = y - 1
597
+ lines.push(child)
598
+ scriptWidth = Math.max(scriptWidth, Math.max(1, child.width))
599
+ child.height = Math.max(29, child.height + 3) - 2
600
+ y += child.height
601
+ line = new Line(y)
602
+ previousChild = null
603
+ } else if (child.isArrow) {
604
+ line.children.push(child)
605
+ previousChild = child
606
+ } else {
607
+ // Remember the last child on the first line
608
+ if (!lines.length) {
609
+ lastChild = child
610
+ }
611
+
612
+ // Leave space between inputs
613
+ if (previousChild) {
614
+ line.width += this.marginBetween(previousChild, child)
615
+ }
616
+
617
+ // Align first input with right of notch
618
+ if (children[0] != null) {
619
+ const cmw = 48 - this.horizontalPadding(children[0])
620
+ if (
621
+ (this.isCommand || this.isOutline) &&
622
+ !child.isLabel &&
623
+ !child.isIcon &&
624
+ line.width < cmw
625
+ ) {
626
+ line.width = cmw
627
+ }
628
+ }
629
+
630
+ // Align extension category icons below notch
631
+ if (child.isIcon && i === 0 && this.isCommand) {
632
+ line.height = Math.max(line.height, child.height + 8)
633
+ }
634
+
635
+ child.x = line.width
636
+ line.width += child.width
637
+ innerWidth = Math.max(innerWidth, line.width)
638
+ if (!child.isLabel) {
639
+ line.height = Math.max(line.height, child.height)
640
+ }
641
+ line.children.push(child)
642
+ previousChild = child
643
+ }
644
+ }
645
+ pushLine()
646
+
647
+ let padLeft = children.length ? this.horizontalPadding(children[0]) : 0
648
+ const padRight = children.length ? this.horizontalPadding(lastChild) : 0
649
+ innerWidth += padLeft + padRight
650
+
651
+ // Commands have a minimum width.
652
+ // Outline min-width is deliberately higher (because Scratch 3 looks silly).
653
+ const originalInnerWidth = innerWidth
654
+ innerWidth = Math.max(
655
+ this.hasScript
656
+ ? 160
657
+ : this.isHat
658
+ ? 100 // Correct for Scratch 3.0.
659
+ : this.isCommand || this.isOutline
660
+ ? 64
661
+ : this.isReporter
662
+ ? 48
663
+ : 0,
664
+ innerWidth,
665
+ )
666
+
667
+ // Center the label text inside small reporters.
668
+ if (this.isReporter) {
669
+ padLeft += (innerWidth - originalInnerWidth) / 2
670
+ }
671
+
672
+ this.height = y
673
+
674
+ this.width = scriptWidth
675
+ ? Math.max(innerWidth, 15 + scriptWidth)
676
+ : innerWidth
677
+ this.firstLine = lines[0]
678
+ this.innerWidth = innerWidth
679
+
680
+ const objects = []
681
+
682
+ for (let i = 0; i < lines.length; i++) {
683
+ const line = lines[i]
684
+ if (line.isScript) {
685
+ objects.push(SVG.move(16, line.y, line.el))
686
+ continue
687
+ }
688
+
689
+ const h = line.height
690
+
691
+ for (let j = 0; j < line.children.length; j++) {
692
+ const child = line.children[j]
693
+ if (child.isArrow) {
694
+ objects.push(SVG.move(innerWidth - 32, this.height - 28, child.el))
695
+ continue
696
+ }
697
+
698
+ let y = pt + (h - child.height - pt - pb) / 2
699
+ if (child.isLabel && i === 0) {
700
+ // We only do this for the first line so that the `else` label is
701
+ // correctly aligned
702
+ y -= 1
703
+ } else if (isDefine && child.isLabel) {
704
+ y += 3
705
+ } else if (child.isIcon) {
706
+ y += child.dy | 0
707
+ if (this.isCommand && i === 0 && j === 0) {
708
+ y += 4
709
+ }
710
+ }
711
+
712
+ let x = padLeft + child.x
713
+ if (child.dx) {
714
+ x += child.dx
715
+ }
716
+
717
+ objects.push(SVG.move(x, (line.y + y) | 0, child.el))
718
+ }
719
+ }
720
+
721
+ const el = this.drawSelf(iconStyle, innerWidth, this.height, lines)
722
+ objects.splice(0, 0, el)
723
+ if (this.info.color) {
724
+ SVG.setProps(el, {
725
+ fill: this.info.color,
726
+ stroke: "rgba(0, 0, 0, 0.2)",
727
+ })
728
+ }
729
+
730
+ const group = SVG.group(objects)
731
+
732
+ // Add data-block-path attribute for highlighting support
733
+ if (this.block && this.block.blockPath) {
734
+ SVG.setProps(group, {
735
+ "data-block-path": this.block.blockPath,
736
+ })
737
+ }
738
+
739
+ return group
740
+ }
741
+ }
742
+
743
+ export class CommentView {
744
+ constructor(comment) {
745
+ Object.assign(this, comment)
746
+ this.label = newView(comment.label)
747
+
748
+ this.width = null
749
+ }
750
+
751
+ get isComment() {
752
+ return true
753
+ }
754
+
755
+ static get lineLength() {
756
+ return 12
757
+ }
758
+
759
+ get height() {
760
+ return 20
761
+ }
762
+
763
+ measure() {
764
+ this.label.measure()
765
+ }
766
+
767
+ draw(iconStyle) {
768
+ const labelEl = this.label.draw(iconStyle)
769
+
770
+ this.width = this.label.width + 16
771
+ return SVG.group([
772
+ SVG.commentLine(this.hasBlock ? CommentView.lineLength : 0, 6),
773
+ SVG.commentRect(this.width, this.height, {
774
+ class: "sb3-comment",
775
+ }),
776
+ SVG.move(8, 4, labelEl),
777
+ ])
778
+ }
779
+ }
780
+
781
+ class GlowView {
782
+ constructor(glow) {
783
+ Object.assign(this, glow)
784
+ this.child = newView(glow.child)
785
+
786
+ this.width = null
787
+ this.height = null
788
+ this.y = 0
789
+ }
790
+
791
+ get isGlow() {
792
+ return true
793
+ }
794
+
795
+ measure() {
796
+ this.child.measure()
797
+ }
798
+
799
+ drawSelf(iconStyle) {
800
+ const c = this.child
801
+ let el
802
+ const w = this.width
803
+ const h = this.height - 1
804
+ if (c.isScript) {
805
+ if (!c.isEmpty && c.blocks[0].isHat) {
806
+ el = SVG.hatRect(w, h)
807
+ } else if (c.isFinal) {
808
+ el = SVG.capRect(w, h)
809
+ } else {
810
+ el = SVG.stackRect(w, h)
811
+ }
812
+ } else {
813
+ el = c.drawSelf(iconStyle, w, h, [])
814
+ }
815
+ return SVG.setProps(el, {
816
+ class: "sb3-diff sb3-diff-ins",
817
+ })
818
+ }
819
+ // TODO how can we always raise Glows above their parents?
820
+
821
+ draw(iconStyle) {
822
+ const c = this.child
823
+ const el = c.isScript ? c.draw(iconStyle, true) : c.draw(iconStyle)
824
+
825
+ this.width = c.width
826
+ this.height = (c.isBlock && c.firstLine.height) || c.height
827
+
828
+ // encircle
829
+ return SVG.group([el, this.drawSelf(iconStyle)])
830
+ }
831
+ }
832
+
833
+ class ScriptView {
834
+ constructor(script) {
835
+ Object.assign(this, script)
836
+ this.blocks = script.blocks.map(newView)
837
+
838
+ this.y = 0
839
+ }
840
+
841
+ get isScript() {
842
+ return true
843
+ }
844
+
845
+ measure() {
846
+ for (const block of this.blocks) {
847
+ block.measure()
848
+ }
849
+ }
850
+
851
+ draw(iconStyle, inside) {
852
+ const children = []
853
+ let y = 1
854
+ this.width = 0
855
+ for (const block of this.blocks) {
856
+ const x = inside ? 0 : 2
857
+ const child = block.draw(iconStyle)
858
+ children.push(SVG.move(x, y, child))
859
+ this.width = Math.max(this.width, block.width)
860
+
861
+ const diff = block.diff
862
+ if (diff === "-") {
863
+ const dw = block.width
864
+ const dh = block.firstLine.height || block.height
865
+ children.push(SVG.move(x, y + dh / 2 + 1, SVG.strikethroughLine(dw)))
866
+ this.width = Math.max(this.width, block.width)
867
+ }
868
+
869
+ y += block.height
870
+
871
+ const comment = block.comment
872
+ if (comment) {
873
+ const line = block.firstLine
874
+ const cx = block.innerWidth + 2 + CommentView.lineLength
875
+ const cy = y - block.height + line.height / 2
876
+ const el = comment.draw(iconStyle)
877
+ children.push(SVG.move(cx, cy - comment.height / 2, el))
878
+ this.width = Math.max(this.width, cx + comment.width)
879
+ }
880
+ }
881
+ const lastBlock = this.blocks[this.blocks.length - 1]
882
+ this.height = y + 1
883
+ if (!inside && !this.isFinal) {
884
+ this.height += lastBlock.hasPuzzle ? 8 : 0
885
+ }
886
+ if (!inside && lastBlock.isGlow) {
887
+ this.height += 7 // TODO unbreak this
888
+ }
889
+ return SVG.group(children)
890
+ }
891
+ }
892
+
893
+ class DocumentView {
894
+ constructor(doc, options) {
895
+ Object.assign(this, doc)
896
+ this.scripts = doc.scripts.map(newView)
897
+
898
+ // Store reference to original document for block lookup
899
+ this.doc = doc
900
+
901
+ this.width = null
902
+ this.height = null
903
+ this.el = null
904
+ this.defs = null
905
+ this.scale = options.scale
906
+ this.iconStyle = options.style.replace("scratch3-", "")
907
+
908
+ // Map of blockPath -> { el, rect } for highlighting
909
+ this.elementMap = new Map()
910
+ }
911
+
912
+ measure() {
913
+ this.scripts.forEach(script => {
914
+ script.measure()
915
+ })
916
+ }
917
+
918
+ render(cb) {
919
+ if (typeof cb === "function") {
920
+ throw new Error("render() no longer takes a callback")
921
+ }
922
+
923
+ // measure strings
924
+ this.measure()
925
+
926
+ // TODO: separate layout + render steps.
927
+ // render each script
928
+ let width = 0
929
+ let height = 0
930
+ const elements = []
931
+ for (let i = 0; i < this.scripts.length; i++) {
932
+ const script = this.scripts[i]
933
+ if (height) {
934
+ height += 10
935
+ }
936
+ script.y = height
937
+ elements.push(SVG.move(0, height, script.draw(this.iconStyle)))
938
+ height += script.height
939
+ if (i !== this.scripts.length - 1) {
940
+ height += 36
941
+ }
942
+ width = Math.max(width, script.width + 4)
943
+ }
944
+ this.width = width
945
+ this.height = height
946
+
947
+ // return SVG
948
+ const svg = SVG.newSVG(width, height, this.scale)
949
+ const icons =
950
+ this.iconStyle === "high-contrast"
951
+ ? makeHighContrastIcons()
952
+ : makeOriginalIcons()
953
+ svg.appendChild((this.defs = SVG.withChildren(SVG.el("defs"), icons)))
954
+
955
+ svg.appendChild(
956
+ SVG.setProps(SVG.group(elements), {
957
+ style: `transform: scale(${this.scale})`,
958
+ }),
959
+ )
960
+ this.el = svg
961
+
962
+ // Build element map after rendering
963
+ this._buildElementMap()
964
+
965
+ return svg
966
+ }
967
+
968
+ /**
969
+ * Build the element map by finding all elements with data-block-path
970
+ */
971
+ _buildElementMap() {
972
+ if (!this.el) return
973
+
974
+ this.elementMap.clear()
975
+ const blocks = this.el.querySelectorAll("[data-block-path]")
976
+ blocks.forEach(el => {
977
+ const path = el.getAttribute("data-block-path")
978
+ if (path) {
979
+ this.elementMap.set(path, { el })
980
+ }
981
+ })
982
+ }
983
+
984
+ /**
985
+ * Get the SVG element for a block by its path
986
+ * @param {string} path - Block path (e.g., "1.2.1")
987
+ * @returns {SVGElement|null}
988
+ */
989
+ getElementByPath(path) {
990
+ const entry = this.elementMap.get(path)
991
+ return entry ? entry.el : null
992
+ }
993
+
994
+ /**
995
+ * Highlight a block by its path
996
+ * @param {string} path - Block path
997
+ * @param {Object} options - { blink: boolean, colorShift: boolean }
998
+ * - blink: if true, the block will blink
999
+ * - colorShift: if true, uses alternate style colors instead of yellow
1000
+ */
1001
+ highlightBlock(path, options = {}) {
1002
+ const el = this.getElementByPath(path)
1003
+ if (!el) return false
1004
+
1005
+ // Add highlight class to the first child (the shape element)
1006
+ const shapeEl = el.firstElementChild
1007
+ if (shapeEl) {
1008
+ // Clear any existing highlight classes first
1009
+ shapeEl.classList.remove(
1010
+ "sb3-highlight",
1011
+ "sb3-highlight-colorShift",
1012
+ "sb3-blink",
1013
+ )
1014
+ // Force browser reflow to reset animation (note: path element does not have offsetWidth)
1015
+ void shapeEl.getBBox()
1016
+
1017
+ // Now add the new highlight classes
1018
+ shapeEl.classList.add("sb3-highlight")
1019
+ if (options.colorShift) {
1020
+ shapeEl.classList.add("sb3-highlight-colorShift")
1021
+ }
1022
+ if (options.blink) {
1023
+ shapeEl.classList.add("sb3-blink")
1024
+ }
1025
+ }
1026
+ return true
1027
+ }
1028
+
1029
+ /**
1030
+ * Clear highlight from a block
1031
+ * @param {string} path - Block path, or null to clear all
1032
+ */
1033
+ clearHighlight(path = null) {
1034
+ if (path) {
1035
+ const el = this.getElementByPath(path)
1036
+ if (el) {
1037
+ const shapeEl = el.firstElementChild
1038
+ if (shapeEl) {
1039
+ shapeEl.classList.remove(
1040
+ "sb3-highlight",
1041
+ "sb3-highlight-colorShift",
1042
+ "sb3-blink",
1043
+ )
1044
+ }
1045
+ }
1046
+ } else {
1047
+ // Clear all highlights
1048
+ const highlighted = this.el.querySelectorAll(".sb3-highlight")
1049
+ highlighted.forEach(el => {
1050
+ el.classList.remove(
1051
+ "sb3-highlight",
1052
+ "sb3-highlight-colorShift",
1053
+ "sb3-blink",
1054
+ )
1055
+ })
1056
+ }
1057
+ }
1058
+
1059
+ /* Export SVG image as XML string */
1060
+ exportSVGString() {
1061
+ if (this.el == null) {
1062
+ throw new Error("call draw() first")
1063
+ }
1064
+
1065
+ const style = makeStyle()
1066
+ this.defs.appendChild(style)
1067
+ const xml = new SVG.XMLSerializer().serializeToString(this.el)
1068
+ this.defs.removeChild(style)
1069
+ return xml
1070
+ }
1071
+
1072
+ /* Export SVG image as data URI */
1073
+ exportSVG() {
1074
+ const xml = this.exportSVGString()
1075
+ return `data:image/svg+xml;utf8,${xml.replace(/[#]/g, encodeURIComponent)}`
1076
+ }
1077
+
1078
+ toCanvas(cb, exportScale) {
1079
+ exportScale = exportScale || 1.0
1080
+
1081
+ const canvas = SVG.makeCanvas()
1082
+ canvas.width = Math.max(1, this.width * exportScale * this.scale)
1083
+ canvas.height = Math.max(1, this.height * exportScale * this.scale)
1084
+ const context = canvas.getContext("2d")
1085
+
1086
+ const image = new Image()
1087
+ image.src = this.exportSVG()
1088
+ image.onload = () => {
1089
+ context.save()
1090
+ context.scale(exportScale, exportScale)
1091
+ context.drawImage(image, 0, 0)
1092
+ context.restore()
1093
+
1094
+ cb(canvas)
1095
+ }
1096
+ }
1097
+
1098
+ exportPNG(cb, scale) {
1099
+ this.toCanvas(canvas => {
1100
+ if (URL && URL.createObjectURL && Blob && canvas.toBlob) {
1101
+ canvas.toBlob(blob => {
1102
+ cb(URL.createObjectURL(blob))
1103
+ }, "image/png")
1104
+ } else {
1105
+ cb(canvas.toDataURL("image/png"))
1106
+ }
1107
+ }, scale)
1108
+ }
1109
+ }
1110
+
1111
+ const viewFor = node => {
1112
+ switch (node.constructor) {
1113
+ case Label:
1114
+ return LabelView
1115
+ case Icon:
1116
+ return IconView
1117
+ case Input:
1118
+ return InputView
1119
+ case Block:
1120
+ return BlockView
1121
+ case Comment:
1122
+ return CommentView
1123
+ case Glow:
1124
+ return GlowView
1125
+ case Script:
1126
+ return ScriptView
1127
+ case Document:
1128
+ return DocumentView
1129
+ default:
1130
+ throw new Error(`no view for ${node.constructor.name}`)
1131
+ }
1132
+ }
1133
+
1134
+ export const newView = (node, options) => new (viewFor(node))(node, options)