scratchblocks
Version:
Make pictures of Scratch blocks from text.
914 lines (798 loc) • 21.8 kB
JavaScript
import {
Label,
Icon,
Input,
Block,
Comment,
Glow,
Script,
Document,
extensions,
aliasExtensions,
} from "../syntax/index.js"
import SVG from "./draw.js"
import style from "./style.js"
const {
defaultFont,
commentFont,
makeStyle,
makeOriginalIcons,
makeHighContrastIcons,
iconName,
} = style
export class LabelView {
constructor(label) {
Object.assign(this, label)
this.el = null
this.height = 12
this.metrics = null
this.x = 0
}
get isLabel() {
return true
}
draw(_iconStyle) {
return this.el
}
get width() {
return this.metrics.width
}
measure() {
const value = this.value
const cls = `sb3-${this.cls}`
this.el = SVG.text(0, 13, value, {
class: `sb3-label ${cls}`,
})
let cache = LabelView.metricsCache[cls]
if (!cache) {
cache = LabelView.metricsCache[cls] = Object.create(null)
}
if (Object.hasOwnProperty.call(cache, value)) {
this.metrics = cache[value]
} else {
const font = /comment-label/.test(this.cls) ? commentFont : defaultFont
this.metrics = cache[value] = LabelView.measure(value, font)
// TODO: word-spacing? (fortunately it seems to have no effect!)
}
}
static measure(value, font) {
const context = LabelView.measuring
context.font = font
const textMetrics = context.measureText(value)
const width = (textMetrics.width + 0.5) | 0
return { width: width }
}
}
LabelView.metricsCache = {}
LabelView.toMeasure = []
export class IconView {
constructor(icon) {
Object.assign(this, icon)
const info = IconView.icons[this.name]
if (!info) {
throw new Error(`no info for icon: ${this.name}`)
}
Object.assign(this, info)
}
get isIcon() {
return true
}
draw(iconStyle) {
return SVG.symbol(`#sb3-${iconName(this.name, iconStyle)}`, {
width: this.width,
height: this.height,
})
}
static get icons() {
return {
greenFlag: { width: 20, height: 21, dy: -2 },
stopSign: { width: 20, height: 20 },
turnLeft: { width: 24, height: 24 },
turnRight: { width: 24, height: 24 },
loopArrow: { width: 24, height: 24 },
addInput: { width: 4, height: 8 },
delInput: { width: 4, height: 8 },
list: { width: 15, height: 18 },
musicBlock: { width: 40, height: 40 },
penBlock: { width: 40, height: 40 },
videoBlock: { width: 40, height: 40, dy: 10 },
ttsBlock: { width: 40, height: 40 },
translateBlock: { width: 40, height: 40 },
wedoBlock: { width: 40, height: 40 },
ev3Block: { width: 40, height: 40 },
microbitBlock: { width: 40, height: 40 },
makeymakeyBlock: { width: 40, height: 40 },
gdxforBlock: { width: 40, height: 40 },
boostBlock: { width: 40, height: 40 },
}
}
}
export class LineView {
constructor() {
this.width = 1
this.height = 40
this.x = 0
}
get isLine() {
return true
}
measure() {}
draw(_iconStyle, parent) {
const category = parent.info.category
return SVG.el("line", {
class: `sb3-${category}-line`,
"stroke-linecap": "round",
x1: 0,
y1: 0,
x2: 0,
y2: 40,
})
}
}
export class InputView {
constructor(input) {
Object.assign(this, input)
if (input.label) {
this.label = newView(input.label)
}
this.isBoolean = this.shape === "boolean"
this.isDropdown = this.shape === "dropdown"
this.isRound = !(this.isBoolean || this.isDropdown)
this.x = 0
}
get isInput() {
return true
}
measure() {
if (this.hasLabel) {
this.label.measure()
}
}
static get shapes() {
return {
string: SVG.pillRect,
number: SVG.pillRect,
"number-dropdown": SVG.pillRect,
color: SVG.pillRect,
dropdown: SVG.roundRect,
boolean: SVG.pointedRect,
stack: SVG.stackRect,
reporter: SVG.pillRect,
}
}
draw(iconStyle, parent) {
let w
let label
if (this.isBoolean) {
w = 48
} else if (this.isColor) {
w = 40
} else if (this.hasLabel) {
label = this.label.draw(iconStyle)
// Minimum padding of 11
// Minimum width of 40, at which point we center the label
const px = this.label.width >= 18 ? 11 : (40 - this.label.width) / 2
w = this.label.width + 2 * px
label = SVG.move(px, 9, label)
} else {
w = this.isInset ? 30 : null
}
if (this.hasArrow) {
w += 20
}
this.width = w
const h = (this.height = 32)
const el = InputView.shapes[this.shape](w, h)
SVG.setProps(el, {
class: `${
this.isColor ? "" : `sb3-${parent.info.category}`
} sb3-input sb3-input-${this.shape}`,
})
if (this.isColor) {
SVG.setProps(el, {
fill: this.value,
})
} else if (this.shape === "dropdown") {
// custom colors
if (parent.info.color) {
SVG.setProps(el, {
fill: parent.info.color,
stroke: "rgba(0, 0, 0, 0.2)",
})
}
} else if (this.shape === "number-dropdown") {
el.classList.add(`sb3-${parent.info.category}-alt`)
// custom colors
if (parent.info.color) {
SVG.setProps(el, {
fill: "rgba(0, 0, 0, 0.1)",
stroke: "rgba(0, 0, 0, 0.15)", // combines with fill...
})
}
} else if (this.shape === "boolean") {
el.classList.remove(`sb3-${parent.info.category}`)
el.classList.add(`sb3-${parent.info.category}-dark`)
// custom colors
if (parent.info.color) {
SVG.setProps(el, {
fill: "rgba(0, 0, 0, 0.15)",
})
}
}
const result = SVG.group([el])
if (this.hasLabel) {
result.appendChild(label)
}
if (this.hasArrow) {
result.appendChild(
SVG.move(
w - 24,
13,
SVG.symbol(
iconStyle === "high-contrast"
? "#sb3-dropdownArrow-high-contrast"
: "#sb3-dropdownArrow",
{},
),
),
)
}
return result
}
}
class BlockView {
constructor(block) {
Object.assign(this, block)
this.children = block.children.map(newView)
this.comment = this.comment ? newView(this.comment) : null
this.isRound = this.isReporter
// Avoid accidental mutation
this.info = { ...block.info }
if (
Object.prototype.hasOwnProperty.call(aliasExtensions, this.info.category)
) {
this.info.category = aliasExtensions[this.info.category]
}
if (Object.prototype.hasOwnProperty.call(extensions, this.info.category)) {
this.children.unshift(new LineView())
this.children.unshift(
new IconView({ name: this.info.category + "Block" }),
)
this.info.category = "extension"
}
this.x = 0
this.width = null
this.height = null
this.firstLine = null
this.innerWidth = null
}
get isBlock() {
return true
}
measure() {
for (const child of this.children) {
if (child.measure) {
child.measure()
}
}
if (this.comment) {
this.comment.measure()
}
}
static get shapes() {
return {
stack: SVG.stackRect,
"c-block": SVG.stackRect,
"if-block": SVG.stackRect,
celse: SVG.stackRect,
cend: SVG.stackRect,
cap: SVG.capRect,
reporter: SVG.pillRect,
boolean: SVG.pointedRect,
hat: SVG.hatRect,
cat: SVG.catHat,
"define-hat": SVG.procHatRect,
ring: SVG.pillRect,
}
}
drawSelf(iconStyle, w, h, lines) {
// mouths
if (lines.length > 1) {
return SVG.mouthRect(w, h, this.isFinal, lines, {
class: `sb3-${this.info.category}`,
})
}
// outlines
if (this.info.shape === "outline") {
return SVG.setProps(SVG.stackRect(w, h), {
class: `sb3-${this.info.category} sb3-${this.info.category}-alt`,
})
}
// rings
if (this.isRing) {
const child = this.children[0]
if (child && (child.isInput || child.isBlock || child.isScript)) {
return SVG.roundRect(w, h, {
class: `sb3-${this.info.category}`,
})
}
}
const func = BlockView.shapes[this.info.shape]
if (!func) {
throw new Error(`no shape func: ${this.info.shape}`)
}
return func(w, h, {
class: `sb3-${this.info.category}`,
})
}
static get padding() {
return {
hat: [24, 8],
cat: [24, 8],
"define-hat": [20, 16],
null: [4, 4],
}
}
horizontalPadding(child) {
if (this.isRound) {
if (child.isIcon) {
return 16
} else if (child.isLabel) {
return 12 // text in circle: 3 units
} else if (child.isDropdown) {
return 12 // square in circle: 3 units
} else if (child.isBoolean) {
return 12 // hexagon in circle: 3 units
} else if (child.isRound) {
return 4 // circle in circle: 1 unit
}
} else if (this.isBoolean) {
if (child.isIcon) {
return 24 // icon in hexagon: ???
} else if (child.isLabel) {
return 20 // text in hexagon: 5 units
} else if (child.isDropdown) {
return 20 // square in hexagon: 5 units
} else if (child.isRound && child.isBlock) {
return 24 // circle in hexagon: 5 + 1 units
} else if (child.isRound) {
return 20 // circle in hexagon: 5 units
} else if (child.isBoolean) {
return 8 // hexagon in hexagon: 2 units
}
}
return 8 // default: 2 units
}
marginBetween(a, b) {
// Consecutive labels should be rendered as a single text element.
// For now, approximate the size of one space
if (a.isLabel && b.isLabel) {
return 5
}
return 8 // default: 2 units
}
draw(iconStyle) {
const isDefine = this.info.shape === "define-hat"
let children = this.children
const isCommand = this.isCommand
const padding = BlockView.padding[this.info.shape] || BlockView.padding.null
const pt = padding[0],
pb = padding[1]
let y = this.info.shape === "cat" ? 16 : 0
const Line = function (y) {
this.y = y
this.width = 0
this.height = isCommand ? 40 : 32
this.children = []
}
let innerWidth = 0
let scriptWidth = 0
let line = new Line(y)
const pushLine = () => {
if (lines.length === 0) {
line.height += pt + pb
} else {
line.height -= 11
line.y -= 2
}
y += line.height
lines.push(line)
}
if (this.info.isRTL) {
let start = 0
const flip = () => {
children = children
.slice(0, start)
.concat(children.slice(start, i).reverse())
.concat(children.slice(i))
}
let i
for (i = 0; i < children.length; i++) {
if (children[i].isScript) {
flip()
start = i + 1
}
}
if (start < i) {
flip()
}
}
const lines = []
let previousChild
let lastChild
for (let i = 0; i < children.length; i++) {
const child = children[i]
child.el = child.draw(iconStyle, this)
if (child.isScript && this.isCommand) {
this.hasScript = true
pushLine()
child.y = y - 1
lines.push(child)
scriptWidth = Math.max(scriptWidth, Math.max(1, child.width))
child.height = Math.max(29, child.height + 3) - 2
y += child.height
line = new Line(y)
previousChild = null
} else if (child.isArrow) {
line.children.push(child)
previousChild = child
} else {
// Remember the last child on the first line
if (!lines.length) {
lastChild = child
}
// Leave space between inputs
if (previousChild) {
line.width += this.marginBetween(previousChild, child)
}
// Align first input with right of notch
if (children[0] != null) {
const cmw = 48 - this.horizontalPadding(children[0])
if (
(this.isCommand || this.isOutline) &&
!child.isLabel &&
!child.isIcon &&
line.width < cmw
) {
line.width = cmw
}
}
// Align extension category icons below notch
if (child.isIcon && i === 0 && this.isCommand) {
line.height = Math.max(line.height, child.height + 8)
}
child.x = line.width
line.width += child.width
innerWidth = Math.max(innerWidth, line.width)
if (!child.isLabel) {
line.height = Math.max(line.height, child.height)
}
line.children.push(child)
previousChild = child
}
}
pushLine()
let padLeft = children.length ? this.horizontalPadding(children[0]) : 0
const padRight = children.length ? this.horizontalPadding(lastChild) : 0
innerWidth += padLeft + padRight
// Commands have a minimum width
// The hat min-width is arbitrary (not sure of Scratch 3 value)
// Outline min-width is deliberately higher (because Scratch 3 looks silly)
const originalInnerWidth = innerWidth
innerWidth = Math.max(
this.hasScript
? 160
: this.isHat
? 108
: this.isCommand || this.isOutline
? 64
: this.isReporter
? 48
: 0,
innerWidth,
)
// Center the label text inside small reporters.
if (this.isReporter) {
padLeft += (innerWidth - originalInnerWidth) / 2
}
this.height = y
this.width = scriptWidth
? Math.max(innerWidth, 15 + scriptWidth)
: innerWidth
this.firstLine = lines[0]
this.innerWidth = innerWidth
const objects = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.isScript) {
objects.push(SVG.move(16, line.y, line.el))
continue
}
const h = line.height
for (let j = 0; j < line.children.length; j++) {
const child = line.children[j]
if (child.isArrow) {
objects.push(SVG.move(innerWidth - 32, this.height - 28, child.el))
continue
}
let y = pt + (h - child.height - pt - pb) / 2
if (child.isLabel && i === 0) {
// We only do this for the first line so that the `else` label is
// correctly aligned
y -= 1
} else if (isDefine && child.isLabel) {
y += 3
} else if (child.isIcon) {
y += child.dy | 0
if (this.isCommand && i === 0 && j === 0) {
y += 4
}
}
let x = padLeft + child.x
if (child.dx) {
x += child.dx
}
objects.push(SVG.move(x, (line.y + y) | 0, child.el))
}
}
const el = this.drawSelf(iconStyle, innerWidth, this.height, lines)
objects.splice(0, 0, el)
if (this.info.color) {
SVG.setProps(el, {
fill: this.info.color,
stroke: "rgba(0, 0, 0, 0.2)",
})
}
return SVG.group(objects)
}
}
export class CommentView {
constructor(comment) {
Object.assign(this, comment)
this.label = newView(comment.label)
this.width = null
}
get isComment() {
return true
}
static get lineLength() {
return 12
}
get height() {
return 20
}
measure() {
this.label.measure()
}
draw(iconStyle) {
const labelEl = this.label.draw(iconStyle)
this.width = this.label.width + 16
return SVG.group([
SVG.commentLine(this.hasBlock ? CommentView.lineLength : 0, 6),
SVG.commentRect(this.width, this.height, {
class: "sb3-comment",
}),
SVG.move(8, 4, labelEl),
])
}
}
class GlowView {
constructor(glow) {
Object.assign(this, glow)
this.child = newView(glow.child)
this.width = null
this.height = null
this.y = 0
}
get isGlow() {
return true
}
measure() {
this.child.measure()
}
drawSelf() {
const c = this.child
let el
const w = this.width
const h = this.height - 1
if (c.isScript) {
if (!c.isEmpty && c.blocks[0].isHat) {
el = SVG.hatRect(w, h)
} else if (c.isFinal) {
el = SVG.capRect(w, h)
} else {
el = SVG.stackRect(w, h)
}
} else {
el = c.drawSelf(w, h, [])
}
return SVG.setProps(el, {
class: "sb3-diff sb3-diff-ins",
})
}
// TODO how can we always raise Glows above their parents?
draw(iconStyle) {
const c = this.child
const el = c.isScript ? c.draw(iconStyle, true) : c.draw(iconStyle)
this.width = c.width
this.height = (c.isBlock && c.firstLine.height) || c.height
// encircle
return SVG.group([el, this.drawSelf()])
}
}
class ScriptView {
constructor(script) {
Object.assign(this, script)
this.blocks = script.blocks.map(newView)
this.y = 0
}
get isScript() {
return true
}
measure() {
for (const block of this.blocks) {
block.measure()
}
}
draw(iconStyle, inside) {
const children = []
let y = 1
this.width = 0
for (const block of this.blocks) {
const x = inside ? 0 : 2
const child = block.draw(iconStyle)
children.push(SVG.move(x, y, child))
this.width = Math.max(this.width, block.width)
const diff = block.diff
if (diff === "-") {
const dw = block.width
const dh = block.firstLine.height || block.height
children.push(SVG.move(x, y + dh / 2 + 1, SVG.strikethroughLine(dw)))
this.width = Math.max(this.width, block.width)
}
y += block.height
const comment = block.comment
if (comment) {
const line = block.firstLine
const cx = block.innerWidth + 2 + CommentView.lineLength
const cy = y - block.height + line.height / 2
const el = comment.draw(iconStyle)
children.push(SVG.move(cx, cy - comment.height / 2, el))
this.width = Math.max(this.width, cx + comment.width)
}
}
const lastBlock = this.blocks[this.blocks.length - 1]
this.height = y + 1
if (!inside && !this.isFinal) {
this.height += lastBlock.hasPuzzle ? 8 : 0
}
if (!inside && lastBlock.isGlow) {
this.height += 7 // TODO unbreak this
}
return SVG.group(children)
}
}
class DocumentView {
constructor(doc, options) {
Object.assign(this, doc)
this.scripts = doc.scripts.map(newView)
this.width = null
this.height = null
this.el = null
this.defs = null
this.scale = options.scale
this.iconStyle = options.style.replace("scratch3-", "")
}
measure() {
this.scripts.forEach(script => {
script.measure()
})
}
render(cb) {
if (typeof cb === "function") {
throw new Error("render() no longer takes a callback")
}
// measure strings
this.measure()
// TODO: separate layout + render steps.
// render each script
let width = 0
let height = 0
const elements = []
for (let i = 0; i < this.scripts.length; i++) {
const script = this.scripts[i]
if (height) {
height += 10
}
script.y = height
elements.push(SVG.move(0, height, script.draw(this.iconStyle)))
height += script.height
if (i !== this.scripts.length - 1) {
height += 36
}
width = Math.max(width, script.width + 4)
}
this.width = width
this.height = height
// return SVG
const svg = SVG.newSVG(width, height, this.scale)
const icons =
this.iconStyle === "high-contrast"
? makeHighContrastIcons()
: makeOriginalIcons()
svg.appendChild((this.defs = SVG.withChildren(SVG.el("defs"), icons)))
svg.appendChild(
SVG.setProps(SVG.group(elements), {
style: `transform: scale(${this.scale})`,
}),
)
this.el = svg
return svg
}
/* Export SVG image as XML string */
exportSVGString() {
if (this.el == null) {
throw new Error("call draw() first")
}
const style = makeStyle()
this.defs.appendChild(style)
const xml = new SVG.XMLSerializer().serializeToString(this.el)
this.defs.removeChild(style)
return xml
}
/* Export SVG image as data URI */
exportSVG() {
const xml = this.exportSVGString()
return `data:image/svg+xml;utf8,${xml.replace(/[#]/g, encodeURIComponent)}`
}
toCanvas(cb, exportScale) {
exportScale = exportScale || 1.0
const canvas = SVG.makeCanvas()
canvas.width = Math.max(1, this.width * exportScale * this.scale)
canvas.height = Math.max(1, this.height * exportScale * this.scale)
const context = canvas.getContext("2d")
const image = new Image()
image.src = this.exportSVG()
image.onload = () => {
context.save()
context.scale(exportScale, exportScale)
context.drawImage(image, 0, 0)
context.restore()
cb(canvas)
}
}
exportPNG(cb, scale) {
this.toCanvas(canvas => {
if (URL && URL.createObjectURL && Blob && canvas.toBlob) {
canvas.toBlob(blob => {
cb(URL.createObjectURL(blob))
}, "image/png")
} else {
cb(canvas.toDataURL("image/png"))
}
}, scale)
}
}
const viewFor = node => {
switch (node.constructor) {
case Label:
return LabelView
case Icon:
return IconView
case Input:
return InputView
case Block:
return BlockView
case Comment:
return CommentView
case Glow:
return GlowView
case Script:
return ScriptView
case Document:
return DocumentView
default:
throw new Error(`no view for ${node.constructor.name}`)
}
}
export const newView = (node, options) => new (viewFor(node))(node, options)