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,1000 @@
1
+ import {
2
+ Label,
3
+ Icon,
4
+ Input,
5
+ Block,
6
+ Comment,
7
+ Glow,
8
+ Script,
9
+ Document,
10
+ extensions,
11
+ movedExtensions,
12
+ aliasExtensions,
13
+ } from "../syntax/index.js"
14
+
15
+ import SVG from "./draw.js"
16
+
17
+ import style from "./style.js"
18
+ const {
19
+ defaultFontFamily,
20
+ makeStyle,
21
+ makeIcons,
22
+ darkRect,
23
+ bevelFilter,
24
+ darkFilter,
25
+ } = style
26
+
27
+ export class LabelView {
28
+ constructor(label) {
29
+ Object.assign(this, label)
30
+
31
+ this.el = null
32
+ this.height = 12
33
+ this.metrics = null
34
+ this.x = 0
35
+ }
36
+
37
+ get isLabel() {
38
+ return true
39
+ }
40
+
41
+ draw() {
42
+ return this.el
43
+ }
44
+
45
+ get width() {
46
+ return this.metrics.width
47
+ }
48
+
49
+ measure() {
50
+ const value = this.value
51
+ const cls = `sb-${this.cls}`
52
+ this.el = SVG.text(0, 10, value, {
53
+ class: `sb-label ${cls}`,
54
+ })
55
+
56
+ let cache = LabelView.metricsCache[cls]
57
+ if (!cache) {
58
+ cache = LabelView.metricsCache[cls] = Object.create(null)
59
+ }
60
+
61
+ if (Object.hasOwnProperty.call(cache, value)) {
62
+ this.metrics = cache[value]
63
+ } else {
64
+ const font = /comment-label/.test(this.cls)
65
+ ? "bold 12px Helvetica, Arial, DejaVu Sans, sans-serif"
66
+ : /literal/.test(this.cls)
67
+ ? `normal 9px ${defaultFontFamily}`
68
+ : `bold 10px ${defaultFontFamily}`
69
+ this.metrics = cache[value] = LabelView.measure(value, font)
70
+ // TODO: word-spacing? (fortunately it seems to have no effect!)
71
+ }
72
+ }
73
+
74
+ static measure(value, font) {
75
+ const context = LabelView.measuring
76
+ context.font = font
77
+ const textMetrics = context.measureText(value)
78
+ const width = (textMetrics.width + 0.5) | 0
79
+ return { width: width }
80
+ }
81
+ }
82
+
83
+ LabelView.metricsCache = {}
84
+ LabelView.toMeasure = []
85
+
86
+ class IconView {
87
+ constructor(icon) {
88
+ Object.assign(this, icon)
89
+
90
+ const info = IconView.icons[this.name]
91
+ if (!info) {
92
+ throw new Error(`no info for icon: ${this.name}`)
93
+ }
94
+ Object.assign(this, info)
95
+ }
96
+
97
+ get isIcon() {
98
+ return true
99
+ }
100
+
101
+ draw() {
102
+ return SVG.symbol(`#${this.name}`, {
103
+ width: this.width,
104
+ height: this.height,
105
+ })
106
+ }
107
+
108
+ static get icons() {
109
+ return {
110
+ greenFlag: { width: 20, height: 21, dy: -2 },
111
+ stopSign: { width: 20, height: 20 },
112
+ turnLeft: { width: 15, height: 12, dy: +1 },
113
+ turnRight: { width: 15, height: 12, dy: +1 },
114
+ loopArrow: { width: 14, height: 11 },
115
+ addInput: { width: 4, height: 8 },
116
+ delInput: { width: 4, height: 8 },
117
+ list: { width: 12, height: 14 },
118
+ }
119
+ }
120
+ }
121
+
122
+ class MatrixView {
123
+ constructor(matrix) {
124
+ Object.assign(this, matrix)
125
+ this.x = 0
126
+
127
+ if (this.rows && this.rows.length > 0) {
128
+ const numRows = this.rows.length
129
+ const numCols = this.rows[0].length
130
+
131
+ // Calculate cell size based on target height and number of rows
132
+ const cellSpacing = 0.5
133
+ const targetHeight = 10 // Target height for matrix display (scratch2 text dropdown is 14, subtract padding)
134
+ const availableHeight = targetHeight - (numRows - 1) * cellSpacing
135
+ this.cellSize = Math.max(1, Math.floor(availableHeight / numRows))
136
+
137
+ // Calculate actual rendered dimensions
138
+ this.width = numCols * (this.cellSize + cellSpacing) - cellSpacing
139
+ this.height = numRows * (this.cellSize + cellSpacing) - cellSpacing
140
+ } else {
141
+ this.width = 0
142
+ this.height = 0
143
+ this.cellSize = 0
144
+ }
145
+ }
146
+
147
+ get isMatrix() {
148
+ return true
149
+ }
150
+
151
+ measure() {
152
+ // Already measured in constructor
153
+ }
154
+
155
+ draw(parent) {
156
+ if (!this.rows || this.rows.length === 0) {
157
+ return SVG.group([])
158
+ }
159
+
160
+ const cellSize = this.cellSize
161
+ const cellSpacing = 0.5
162
+ const totalCellSize = cellSize + cellSpacing
163
+ const elements = []
164
+
165
+ // Draw matrix cells
166
+ for (let rowIdx = 0; rowIdx < this.rows.length; rowIdx++) {
167
+ const row = this.rows[rowIdx]
168
+ for (let colIdx = 0; colIdx < row.length; colIdx++) {
169
+ const cell = row[colIdx]
170
+ const x = colIdx * totalCellSize
171
+ const y = rowIdx * totalCellSize
172
+
173
+ const isFilled = cell === true
174
+
175
+ // Use custom color or category-based styling
176
+ const rect = SVG.el("rect", {
177
+ x: x,
178
+ y: y,
179
+ width: cellSize,
180
+ height: cellSize,
181
+ "stroke-width": 0,
182
+ })
183
+
184
+ if (isFilled) {
185
+ rect.setAttribute("fill", "#FFFFFF")
186
+ } else {
187
+ rect.classList.add(`sb-${parent.info.category}`)
188
+ }
189
+
190
+ elements.push(rect)
191
+ }
192
+ }
193
+
194
+ return SVG.group(elements)
195
+ }
196
+ }
197
+
198
+ class InputView {
199
+ constructor(input) {
200
+ Object.assign(this, input)
201
+ if (input.label) {
202
+ this.label = newView(input.label)
203
+ }
204
+ // Create MatrixView if value is a Matrix
205
+ if (input.value && input.value.isMatrix) {
206
+ this.matrixView = new MatrixView(input.value)
207
+ }
208
+
209
+ this.x = 0
210
+ }
211
+
212
+ measure() {
213
+ if (this.hasLabel) {
214
+ this.label.measure()
215
+ }
216
+ if (this.matrixView) {
217
+ this.matrixView.measure()
218
+ }
219
+ }
220
+
221
+ static get shapes() {
222
+ return {
223
+ string: SVG.rect,
224
+ number: SVG.roundedRect,
225
+ "number-dropdown": SVG.roundedRect,
226
+ color: SVG.rect,
227
+ dropdown: SVG.rect,
228
+
229
+ boolean: SVG.pointedRect,
230
+ stack: SVG.stackRect,
231
+ reporter: SVG.roundedRect,
232
+ }
233
+ }
234
+
235
+ draw(parent) {
236
+ let w
237
+ let label
238
+ let px
239
+
240
+ // Check if this has a matrix view
241
+ const hasMatrix = !!this.matrixView
242
+
243
+ if (hasMatrix) {
244
+ // Use same padding as text dropdowns for consistency
245
+ px = 4
246
+ const matrixWidth = this.matrixView.width
247
+ w = matrixWidth + px + 4 // Left padding + right margin before arrow
248
+ this.height = 14 // Fixed height matching scratch2 text dropdown
249
+ } else if (this.hasLabel) {
250
+ label = this.label.draw()
251
+ w = Math.max(
252
+ 14,
253
+ this.label.width +
254
+ (this.shape === "string" || this.shape === "number-dropdown" ? 6 : 9),
255
+ )
256
+ } else {
257
+ w = this.isInset ? 30 : this.isColor ? 13 : null
258
+ }
259
+ if (this.hasArrow) {
260
+ w += 10
261
+ }
262
+ this.width = w
263
+
264
+ const h = (this.height = hasMatrix
265
+ ? 14
266
+ : this.isRound || this.isColor
267
+ ? 13
268
+ : 14)
269
+
270
+ // For matrix inputs, use rounded rect shape but with dropdown styling
271
+ const shapeForRender = hasMatrix ? "number-dropdown" : this.shape
272
+ let el = InputView.shapes[shapeForRender](w, h)
273
+
274
+ if (this.isColor) {
275
+ SVG.setProps(el, {
276
+ fill: this.value,
277
+ })
278
+ } else if (this.isDarker || hasMatrix) {
279
+ // Apply darkRect styling for dropdown-like appearance
280
+ el = darkRect(w, h, parent.info.category, el)
281
+ if (parent.info.color) {
282
+ SVG.setProps(el, {
283
+ fill: parent.info.color,
284
+ })
285
+ }
286
+ }
287
+
288
+ const result = SVG.group([
289
+ SVG.setProps(el, {
290
+ class: `sb-input sb-input-${hasMatrix ? "number-dropdown" : this.shape}`,
291
+ }),
292
+ ])
293
+
294
+ // Render matrix content using MatrixView
295
+ if (hasMatrix) {
296
+ const matrixStartX = px
297
+ const matrixStartY = (h - this.matrixView.height) / 2
298
+
299
+ const matrixEl = this.matrixView.draw(parent)
300
+ result.appendChild(SVG.move(matrixStartX, matrixStartY, matrixEl))
301
+ }
302
+
303
+ if (this.hasLabel) {
304
+ const x = this.isRound ? 5 : 4
305
+ result.appendChild(SVG.move(x, 0, label))
306
+ }
307
+ if (this.hasArrow) {
308
+ const y = this.shape === "dropdown" ? 5 : 4
309
+ result.appendChild(
310
+ SVG.move(
311
+ w - 10,
312
+ y,
313
+ SVG.polygon({
314
+ points: [7, 0, 3.5, 4, 0, 0],
315
+ fill: "#000",
316
+ opacity: "0.6",
317
+ }),
318
+ ),
319
+ )
320
+ }
321
+ return result
322
+ }
323
+ }
324
+
325
+ class BlockView {
326
+ constructor(block) {
327
+ Object.assign(this, block)
328
+ this.children = block.children.map(newView)
329
+ this.comment = this.comment ? newView(this.comment) : null
330
+
331
+ // Store the original block for path reference
332
+ this.block = block
333
+
334
+ if (
335
+ Object.prototype.hasOwnProperty.call(aliasExtensions, this.info.category)
336
+ ) {
337
+ // handle aliases first
338
+ this.info.category = aliasExtensions[this.info.category]
339
+ }
340
+ if (
341
+ Object.prototype.hasOwnProperty.call(movedExtensions, this.info.category)
342
+ ) {
343
+ this.info.category = movedExtensions[this.info.category]
344
+ } else if (
345
+ Object.prototype.hasOwnProperty.call(extensions, this.info.category)
346
+ ) {
347
+ this.info.category = "extension"
348
+ }
349
+
350
+ this.x = 0
351
+ this.width = null
352
+ this.height = null
353
+ this.firstLine = null
354
+ this.innerWidth = null
355
+ }
356
+
357
+ get isBlock() {
358
+ return true
359
+ }
360
+
361
+ measure() {
362
+ for (const child of this.children) {
363
+ if (child.measure) {
364
+ child.measure()
365
+ }
366
+ }
367
+ if (this.comment) {
368
+ this.comment.measure()
369
+ }
370
+ }
371
+
372
+ static get shapes() {
373
+ return {
374
+ stack: SVG.stackRect,
375
+ "c-block": SVG.stackRect,
376
+ "if-block": SVG.stackRect,
377
+ celse: SVG.stackRect,
378
+ cend: SVG.stackRect,
379
+
380
+ cap: SVG.capRect,
381
+ reporter: SVG.roundedRect,
382
+ boolean: SVG.pointedRect,
383
+ hat: SVG.hatRect,
384
+ cat: SVG.hatRect,
385
+ "define-hat": SVG.procHatRect,
386
+ ring: SVG.roundedRect,
387
+ }
388
+ }
389
+
390
+ drawSelf(w, h, lines) {
391
+ // mouths
392
+ if (lines.length > 1) {
393
+ return SVG.mouthRect(w, h, this.isFinal, lines, {
394
+ class: `sb-${this.info.category} sb-bevel`,
395
+ })
396
+ }
397
+
398
+ // outlines
399
+ if (this.info.shape === "outline") {
400
+ return SVG.setProps(SVG.stackRect(w, h), {
401
+ class: "sb-outline",
402
+ })
403
+ }
404
+
405
+ // rings
406
+ if (this.isRing) {
407
+ const child = this.children[0]
408
+ // We use isStack for InputView; isBlock for BlockView; isScript for ScriptView.
409
+ if (child && (child.isStack || child.isBlock || child.isScript)) {
410
+ const shape = child.isScript
411
+ ? "stack"
412
+ : child.isStack
413
+ ? child.shape
414
+ : child.info.shape
415
+ return SVG.ringRect(w, h, child.y, child.width, child.height, shape, {
416
+ class: `sb-${this.info.category} sb-bevel`,
417
+ })
418
+ }
419
+ }
420
+
421
+ const func = BlockView.shapes[this.info.shape]
422
+ if (!func) {
423
+ throw new Error(`no shape func: ${this.info.shape}`)
424
+ }
425
+ return func(w, h, {
426
+ class: `sb-${this.info.category} sb-bevel`,
427
+ })
428
+ }
429
+
430
+ minDistance(child) {
431
+ if (this.isBoolean) {
432
+ return child.isReporter
433
+ ? (4 + child.height / 4) | 0
434
+ : child.isLabel
435
+ ? (5 + child.height / 2) | 0
436
+ : child.isBoolean || child.shape === "boolean"
437
+ ? 5
438
+ : (2 + child.height / 2) | 0
439
+ }
440
+ if (this.isReporter) {
441
+ return (child.isInput && child.isRound) ||
442
+ ((child.isReporter || child.isBoolean) && !child.hasScript)
443
+ ? 0
444
+ : child.isLabel
445
+ ? (2 + child.height / 2) | 0
446
+ : (-2 + child.height / 2) | 0
447
+ }
448
+ return 0
449
+ }
450
+
451
+ static get padding() {
452
+ return {
453
+ hat: [15, 6, 2],
454
+ cat: [15, 6, 2],
455
+ "define-hat": [21, 8, 9],
456
+ reporter: [3, 4, 1],
457
+ boolean: [3, 4, 2],
458
+ cap: [6, 6, 2],
459
+ "c-block": [3, 6, 2],
460
+ "if-block": [3, 6, 2],
461
+ ring: [4, 4, 2],
462
+ null: [4, 6, 2],
463
+ }
464
+ }
465
+
466
+ draw() {
467
+ const isDefine = this.info.shape === "define-hat"
468
+ let children = this.children
469
+
470
+ const padding = BlockView.padding[this.info.shape] || BlockView.padding.null
471
+ let pt = padding[0]
472
+ const px = padding[1]
473
+ const pb = padding[2]
474
+
475
+ let y = 0
476
+ const Line = function (y) {
477
+ this.y = y
478
+ this.width = 0
479
+ this.height = y ? 13 : 16
480
+ this.children = []
481
+ }
482
+
483
+ let innerWidth = 0
484
+ let scriptWidth = 0
485
+ let line = new Line(y)
486
+ const pushLine = isLast => {
487
+ if (lines.length === 0) {
488
+ line.height += pt + pb
489
+ } else {
490
+ line.height += isLast ? 0 : +2
491
+ line.y -= 1
492
+ }
493
+ y += line.height
494
+ lines.push(line)
495
+ }
496
+
497
+ if (this.info.isRTL) {
498
+ let start = 0
499
+ const flip = () => {
500
+ children = children
501
+ .slice(0, start)
502
+ .concat(children.slice(start, i).reverse())
503
+ .concat(children.slice(i))
504
+ }
505
+ let i
506
+ for (i = 0; i < children.length; i++) {
507
+ if (children[i].isScript) {
508
+ flip()
509
+ start = i + 1
510
+ }
511
+ }
512
+ if (start < i) {
513
+ flip()
514
+ }
515
+ }
516
+
517
+ const lines = []
518
+ for (let i = 0; i < children.length; i++) {
519
+ const child = children[i]
520
+ child.el = child.draw(this)
521
+
522
+ if (child.isScript && this.isCommand) {
523
+ this.hasScript = true
524
+ pushLine()
525
+ child.y = y
526
+ lines.push(child)
527
+ scriptWidth = Math.max(scriptWidth, Math.max(1, child.width))
528
+ child.height = Math.max(12, child.height) + 3
529
+ y += child.height
530
+ line = new Line(y)
531
+ } else if (child.isArrow) {
532
+ line.children.push(child)
533
+ } else {
534
+ const cmw = i > 0 ? 30 : 0 // 27
535
+ const md = this.isCommand ? 0 : this.minDistance(child)
536
+ const mw = this.isCommand
537
+ ? child.isBlock || child.isInput
538
+ ? cmw
539
+ : 0
540
+ : md
541
+ if (mw && !lines.length && line.width < mw - px) {
542
+ line.width = mw - px
543
+ }
544
+ child.x = line.width
545
+ line.width += child.width
546
+ innerWidth = Math.max(innerWidth, line.width + Math.max(0, md - px))
547
+ line.width += 4
548
+ if (!child.isLabel) {
549
+ line.height = Math.max(line.height, child.height)
550
+ }
551
+ line.children.push(child)
552
+ }
553
+ }
554
+ pushLine(true)
555
+
556
+ innerWidth = Math.max(
557
+ innerWidth + px * 2,
558
+ this.isHat || this.hasScript
559
+ ? 83
560
+ : this.isCommand || this.isOutline || this.isRing
561
+ ? 39
562
+ : 20,
563
+ )
564
+ this.height = y
565
+ this.width = scriptWidth
566
+ ? Math.max(innerWidth, 15 + scriptWidth)
567
+ : innerWidth
568
+ if (isDefine) {
569
+ const p = Math.min(26, (3.5 + 0.13 * innerWidth) | 0) - 18
570
+ this.height += p
571
+ pt += 2 * p
572
+ }
573
+ this.firstLine = lines[0]
574
+ this.innerWidth = innerWidth
575
+
576
+ const objects = []
577
+
578
+ for (const line of lines) {
579
+ if (line.isScript) {
580
+ objects.push(SVG.move(15, line.y, line.el))
581
+ continue
582
+ }
583
+
584
+ const h = line.height
585
+
586
+ for (const child of line.children) {
587
+ if (child.isArrow) {
588
+ objects.push(SVG.move(innerWidth - 15, this.height - 3, child.el))
589
+ continue
590
+ }
591
+
592
+ let y = pt + (h - child.height - pt - pb) / 2 - 1
593
+ if (isDefine && child.isLabel) {
594
+ y += 3
595
+ } else if (child.isIcon) {
596
+ y += child.dy | 0
597
+ }
598
+ if (this.isRing) {
599
+ child.y = (line.y + y) | 0
600
+ if (child.isInset) {
601
+ continue
602
+ }
603
+ }
604
+ objects.push(SVG.move(px + child.x, (line.y + y) | 0, child.el))
605
+
606
+ if (child.diff === "+") {
607
+ const ellipse = SVG.insEllipse(child.width, child.height)
608
+ objects.push(SVG.move(px + child.x, (line.y + y) | 0, ellipse))
609
+ }
610
+ }
611
+ }
612
+
613
+ const el = this.drawSelf(innerWidth, this.height, lines)
614
+ objects.splice(0, 0, el)
615
+ if (this.info.color) {
616
+ SVG.setProps(el, {
617
+ fill: this.info.color,
618
+ })
619
+ }
620
+
621
+ const group = SVG.group(objects)
622
+
623
+ // Add data-block-path attribute for highlighting support
624
+ if (this.block && this.block.blockPath) {
625
+ SVG.setProps(group, {
626
+ "data-block-path": this.block.blockPath,
627
+ })
628
+ }
629
+
630
+ return group
631
+ }
632
+ }
633
+
634
+ class CommentView {
635
+ constructor(comment) {
636
+ Object.assign(this, comment)
637
+ this.label = newView(comment.label)
638
+
639
+ this.width = null
640
+ }
641
+
642
+ get isComment() {
643
+ return true
644
+ }
645
+
646
+ static get lineLength() {
647
+ return 12
648
+ }
649
+
650
+ get height() {
651
+ return 20
652
+ }
653
+
654
+ measure() {
655
+ this.label.measure()
656
+ }
657
+
658
+ draw() {
659
+ const labelEl = this.label.draw()
660
+
661
+ this.width = this.label.width + 16
662
+ return SVG.group([
663
+ SVG.commentLine(this.hasBlock ? CommentView.lineLength : 0, 6),
664
+ SVG.commentRect(this.width, this.height, {
665
+ class: "sb-comment",
666
+ }),
667
+ SVG.move(8, 4, labelEl),
668
+ ])
669
+ }
670
+ }
671
+
672
+ class GlowView {
673
+ constructor(glow) {
674
+ Object.assign(this, glow)
675
+ this.child = newView(glow.child)
676
+
677
+ this.width = null
678
+ this.height = null
679
+ this.y = 0
680
+ }
681
+
682
+ get isGlow() {
683
+ return true
684
+ }
685
+
686
+ measure() {
687
+ this.child.measure()
688
+ }
689
+
690
+ drawSelf() {
691
+ const c = this.child
692
+ let el
693
+ const w = this.width
694
+ const h = this.height - 1
695
+ if (c.isScript) {
696
+ if (!c.isEmpty && c.blocks[0].isHat) {
697
+ el = SVG.hatRect(w, h)
698
+ } else if (c.isFinal) {
699
+ el = SVG.capRect(w, h)
700
+ } else {
701
+ el = SVG.stackRect(w, h)
702
+ }
703
+ } else {
704
+ el = c.drawSelf(w, h, [])
705
+ }
706
+ return SVG.setProps(el, {
707
+ class: "sb-diff sb-diff-ins",
708
+ })
709
+ }
710
+ // TODO how can we always raise Glows above their parents?
711
+
712
+ draw() {
713
+ const c = this.child
714
+ const el = c.isScript ? c.draw(true) : c.draw()
715
+
716
+ this.width = c.width
717
+ this.height = (c.isBlock && c.firstLine.height) || c.height
718
+
719
+ // encircle
720
+ return SVG.group([el, this.drawSelf()])
721
+ }
722
+ }
723
+
724
+ class ScriptView {
725
+ constructor(script) {
726
+ Object.assign(this, script)
727
+ this.blocks = script.blocks.map(newView)
728
+
729
+ this.y = 0
730
+ }
731
+
732
+ get isScript() {
733
+ return true
734
+ }
735
+
736
+ measure() {
737
+ for (const block of this.blocks) {
738
+ block.measure()
739
+ }
740
+ }
741
+
742
+ draw(inside) {
743
+ const children = []
744
+ let y = 0
745
+ this.width = 0
746
+ for (const block of this.blocks) {
747
+ const x = inside ? 0 : 2
748
+ const child = block.draw()
749
+ children.push(SVG.move(x, y, child))
750
+ this.width = Math.max(this.width, block.width)
751
+
752
+ const diff = block.diff
753
+ if (diff === "-") {
754
+ const dw = block.width
755
+ const dh = block.firstLine.height || block.height
756
+ children.push(SVG.move(x, y + dh / 2 + 1, SVG.strikethroughLine(dw)))
757
+ this.width = Math.max(this.width, block.width)
758
+ }
759
+
760
+ y += block.height
761
+
762
+ const comment = block.comment
763
+ if (comment) {
764
+ const line = block.firstLine
765
+ const cx = block.innerWidth + 2 + CommentView.lineLength
766
+ const cy = y - block.height + line.height / 2
767
+ const el = comment.draw()
768
+ children.push(SVG.move(cx, cy - comment.height / 2, el))
769
+ this.width = Math.max(this.width, cx + comment.width)
770
+ }
771
+ }
772
+ this.height = y
773
+ if (!inside && !this.isFinal) {
774
+ this.height += 3
775
+ }
776
+ const lastBlock = this.blocks[this.blocks.length - 1]
777
+ if (!inside && lastBlock.isGlow) {
778
+ this.height += 2 // TODO unbreak this
779
+ }
780
+ return SVG.group(children)
781
+ }
782
+ }
783
+
784
+ class DocumentView {
785
+ constructor(doc, options) {
786
+ Object.assign(this, doc)
787
+ this.scripts = doc.scripts.map(newView)
788
+
789
+ // Store reference to original document for block lookup
790
+ this.doc = doc
791
+
792
+ this.width = null
793
+ this.height = null
794
+ this.el = null
795
+ this.defs = null
796
+ this.scale = options.scale
797
+
798
+ // Map of blockPath -> { el } for highlighting
799
+ this.elementMap = new Map()
800
+ }
801
+
802
+ measure() {
803
+ this.scripts.forEach(script => script.measure())
804
+ }
805
+
806
+ render(cb) {
807
+ if (typeof cb === "function") {
808
+ throw new Error("render() no longer takes a callback")
809
+ }
810
+
811
+ // measure strings
812
+ this.measure()
813
+
814
+ // TODO: separate layout + render steps.
815
+ // render each script
816
+ let width = 0
817
+ let height = 0
818
+ const elements = []
819
+ for (const script of this.scripts) {
820
+ if (height) {
821
+ height += 10
822
+ }
823
+ script.y = height
824
+ elements.push(SVG.move(0, height, script.draw()))
825
+ height += script.height
826
+ width = Math.max(width, script.width + 4)
827
+ }
828
+ this.width = width
829
+ this.height = height
830
+
831
+ // return SVG
832
+ const svg = SVG.newSVG(width, height, this.scale)
833
+ svg.appendChild(
834
+ (this.defs = SVG.withChildren(SVG.el("defs"), [
835
+ bevelFilter("bevelFilter", false),
836
+ bevelFilter("inputBevelFilter", true),
837
+ darkFilter("inputDarkFilter"),
838
+ ...makeIcons(),
839
+ ])),
840
+ )
841
+
842
+ svg.appendChild(SVG.group(elements))
843
+ this.el = svg
844
+
845
+ // Build element map after rendering
846
+ this._buildElementMap()
847
+
848
+ return svg
849
+ }
850
+
851
+ /**
852
+ * Build the element map by finding all elements with data-block-path
853
+ */
854
+ _buildElementMap() {
855
+ if (!this.el) return
856
+
857
+ this.elementMap.clear()
858
+ const blocks = this.el.querySelectorAll("[data-block-path]")
859
+ blocks.forEach(el => {
860
+ const path = el.getAttribute("data-block-path")
861
+ if (path) {
862
+ this.elementMap.set(path, { el })
863
+ }
864
+ })
865
+ }
866
+
867
+ /**
868
+ * Get the SVG element for a block by its path
869
+ * @param {string} path - Block path (e.g., "1.2.1")
870
+ * @returns {SVGElement|null}
871
+ */
872
+ getElementByPath(path) {
873
+ const entry = this.elementMap.get(path)
874
+ return entry ? entry.el : null
875
+ }
876
+
877
+ /**
878
+ * Highlight a block by its path
879
+ * @param {string} path - Block path
880
+ * @param {Object} options - { blink: boolean }
881
+ */
882
+ highlightBlock(path, options = {}) {
883
+ const el = this.getElementByPath(path)
884
+ if (!el) return false
885
+
886
+ // Add highlight class to the first child (the shape element)
887
+ const shapeEl = el.firstElementChild
888
+ if (shapeEl) {
889
+ // Clear any existing highlight classes first
890
+ shapeEl.classList.remove("sb-highlight", "sb-blink")
891
+ // Force browser reflow to reset animation
892
+ void shapeEl.getBBox()
893
+
894
+ // Now add the new highlight classes
895
+ shapeEl.classList.add("sb-highlight")
896
+ if (options.blink) {
897
+ shapeEl.classList.add("sb-blink")
898
+ }
899
+ }
900
+ return true
901
+ }
902
+
903
+ /**
904
+ * Clear highlight from a block
905
+ * @param {string} path - Block path, or null to clear all
906
+ */
907
+ clearHighlight(path = null) {
908
+ if (path) {
909
+ const el = this.getElementByPath(path)
910
+ if (el) {
911
+ const shapeEl = el.firstElementChild
912
+ if (shapeEl) {
913
+ shapeEl.classList.remove("sb-highlight", "sb-blink")
914
+ }
915
+ }
916
+ } else {
917
+ // Clear all highlights
918
+ const highlighted = this.el.querySelectorAll(".sb-highlight")
919
+ highlighted.forEach(el => {
920
+ el.classList.remove("sb-highlight", "sb-blink")
921
+ })
922
+ }
923
+ }
924
+
925
+ /* Export SVG image as XML string */
926
+ exportSVGString() {
927
+ if (this.el == null) {
928
+ throw new Error("call draw() first")
929
+ }
930
+
931
+ const style = makeStyle()
932
+ this.defs.appendChild(style)
933
+ const xml = new SVG.XMLSerializer().serializeToString(this.el)
934
+ this.defs.removeChild(style)
935
+ return xml
936
+ }
937
+
938
+ /* Export SVG image as data URI */
939
+ exportSVG() {
940
+ const xml = this.exportSVGString()
941
+ return `data:image/svg+xml;utf8,${xml.replace(/[#]/g, encodeURIComponent)}`
942
+ }
943
+
944
+ toCanvas(cb, exportScale) {
945
+ exportScale = exportScale || 1.0
946
+
947
+ const canvas = SVG.makeCanvas()
948
+ canvas.width = Math.max(1, this.width * exportScale * this.scale)
949
+ canvas.height = Math.max(1, this.height * exportScale * this.scale)
950
+ const context = canvas.getContext("2d")
951
+
952
+ const image = new Image()
953
+ image.src = this.exportSVG()
954
+ image.onload = () => {
955
+ context.save()
956
+ context.scale(exportScale, exportScale)
957
+ context.drawImage(image, 0, 0)
958
+ context.restore()
959
+
960
+ cb(canvas)
961
+ }
962
+ }
963
+
964
+ exportPNG(cb, scale) {
965
+ this.toCanvas(canvas => {
966
+ if (URL && URL.createObjectURL && Blob && canvas.toBlob) {
967
+ canvas.toBlob(blob => {
968
+ cb(URL.createObjectURL(blob))
969
+ }, "image/png")
970
+ } else {
971
+ cb(canvas.toDataURL("image/png"))
972
+ }
973
+ }, scale)
974
+ }
975
+ }
976
+
977
+ const viewFor = node => {
978
+ switch (node.constructor) {
979
+ case Label:
980
+ return LabelView
981
+ case Icon:
982
+ return IconView
983
+ case Input:
984
+ return InputView
985
+ case Block:
986
+ return BlockView
987
+ case Comment:
988
+ return CommentView
989
+ case Glow:
990
+ return GlowView
991
+ case Script:
992
+ return ScriptView
993
+ case Document:
994
+ return DocumentView
995
+ default:
996
+ throw new Error(`no view for ${node.constructor.name}`)
997
+ }
998
+ }
999
+
1000
+ export const newView = (node, options) => new (viewFor(node))(node, options)