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.
- package/LICENSE +19 -0
- package/README.md +193 -0
- package/browser.es.js +8 -0
- package/browser.js +8 -0
- package/build/scratchblocks-plus.min.es.js +12 -0
- package/build/scratchblocks-plus.min.es.js.map +1 -0
- package/build/scratchblocks-plus.min.js +12 -0
- package/build/scratchblocks-plus.min.js.map +1 -0
- package/build/translations-all-es.js +11 -0
- package/build/translations-all-es.js.map +1 -0
- package/build/translations-all.js +11 -0
- package/build/translations-all.js.map +1 -0
- package/build/translations-es.js +11 -0
- package/build/translations-es.js.map +1 -0
- package/build/translations.js +11 -0
- package/build/translations.js.map +1 -0
- package/index.d.ts +297 -0
- package/index.js +229 -0
- package/locales/ab.json +1630 -0
- package/locales/af.json +1630 -0
- package/locales/all.d.ts +108 -0
- package/locales/all.js +161 -0
- package/locales/am.json +1925 -0
- package/locales/an.json +1630 -0
- package/locales/ar.json +1924 -0
- package/locales/ast.json +1630 -0
- package/locales/az.json +1925 -0
- package/locales/be.json +1630 -0
- package/locales/bg.json +1924 -0
- package/locales/bn.json +1630 -0
- package/locales/ca.json +1930 -0
- package/locales/ckb.json +1630 -0
- package/locales/cs.json +1930 -0
- package/locales/cy.json +1929 -0
- package/locales/da.json +1924 -0
- package/locales/de.json +1929 -0
- package/locales/el.json +1931 -0
- package/locales/eo.json +1630 -0
- package/locales/es-419.json +1924 -0
- package/locales/es.json +1929 -0
- package/locales/et.json +1924 -0
- package/locales/eu.json +1924 -0
- package/locales/fa.json +1929 -0
- package/locales/fi.json +1924 -0
- package/locales/fil.json +1631 -0
- package/locales/forums.js +37 -0
- package/locales/fr.json +1929 -0
- package/locales/fy.json +1630 -0
- package/locales/ga.json +1924 -0
- package/locales/gd.json +1929 -0
- package/locales/gl.json +1924 -0
- package/locales/ha.json +1630 -0
- package/locales/he.json +1929 -0
- package/locales/hi.json +1635 -0
- package/locales/hr.json +1929 -0
- package/locales/ht.json +1630 -0
- package/locales/hu.json +1930 -0
- package/locales/hy.json +1630 -0
- package/locales/id.json +1929 -0
- package/locales/is.json +1924 -0
- package/locales/it.json +1929 -0
- package/locales/ja-Hira.json +1637 -0
- package/locales/ja.json +1931 -0
- package/locales/ka.json +1630 -0
- package/locales/kk.json +1632 -0
- package/locales/km.json +1630 -0
- package/locales/ko.json +1924 -0
- package/locales/ku.json +1632 -0
- package/locales/lt.json +1924 -0
- package/locales/lv.json +1924 -0
- package/locales/mi.json +1924 -0
- package/locales/mn.json +1631 -0
- package/locales/nb.json +1929 -0
- package/locales/nl.json +1929 -0
- package/locales/nn.json +1630 -0
- package/locales/nso.json +1630 -0
- package/locales/oc.json +1630 -0
- package/locales/or.json +1631 -0
- package/locales/pl.json +1929 -0
- package/locales/pt-br.json +1924 -0
- package/locales/pt.json +1929 -0
- package/locales/qu.json +1630 -0
- package/locales/rap.json +1632 -0
- package/locales/ro.json +1929 -0
- package/locales/ru.json +1929 -0
- package/locales/sk.json +1924 -0
- package/locales/sl.json +1929 -0
- package/locales/sr.json +1924 -0
- package/locales/sv.json +1924 -0
- package/locales/sw.json +1630 -0
- package/locales/th.json +1924 -0
- package/locales/tn.json +1630 -0
- package/locales/tr.json +1932 -0
- package/locales/uk.json +1924 -0
- package/locales/uz.json +1631 -0
- package/locales/vi.json +1925 -0
- package/locales/xh.json +1630 -0
- package/locales/zh-cn.json +1930 -0
- package/locales/zh-tw.json +1930 -0
- package/locales/zu.json +1918 -0
- package/package.json +81 -0
- package/scratch2/blocks.js +1000 -0
- package/scratch2/draw.js +452 -0
- package/scratch2/filter.js +78 -0
- package/scratch2/index.js +12 -0
- package/scratch2/style.css.js +148 -0
- package/scratch2/style.js +214 -0
- package/scratch3/blocks.js +1134 -0
- package/scratch3/draw.js +334 -0
- package/scratch3/index.js +12 -0
- package/scratch3/style.css.js +280 -0
- package/scratch3/style.js +877 -0
- package/syntax/blocks.js +921 -0
- package/syntax/commands.js +1755 -0
- package/syntax/dropdowns.js +688 -0
- package/syntax/extensions.js +34 -0
- package/syntax/index.js +17 -0
- package/syntax/model.js +566 -0
- 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)
|