UNPKG

scratchblocks

Version:

Make pictures of Scratch blocks from text.

793 lines (695 loc) 18.1 kB
import { Label, Icon, Input, Block, Comment, Glow, Script, Document, extensions, movedExtensions, aliasExtensions, } from "../syntax/index.js" import SVG from "./draw.js" import style from "./style.js" const { defaultFontFamily, makeStyle, makeIcons, darkRect, bevelFilter, darkFilter, } = 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() { return this.el } get width() { return this.metrics.width } measure() { const value = this.value const cls = `sb-${this.cls}` this.el = SVG.text(0, 10, value, { class: `sb-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) ? "bold 12px Helvetica, Arial, DejaVu Sans, sans-serif" : /literal/.test(this.cls) ? `normal 9px ${defaultFontFamily}` : `bold 10px ${defaultFontFamily}` 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 = [] 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() { return SVG.symbol(`#${this.name}`, { width: this.width, height: this.height, }) } static get icons() { return { greenFlag: { width: 20, height: 21, dy: -2 }, stopSign: { width: 20, height: 20 }, turnLeft: { width: 15, height: 12, dy: +1 }, turnRight: { width: 15, height: 12, dy: +1 }, loopArrow: { width: 14, height: 11 }, addInput: { width: 4, height: 8 }, delInput: { width: 4, height: 8 }, list: { width: 12, height: 14 }, } } } class InputView { constructor(input) { Object.assign(this, input) if (input.label) { this.label = newView(input.label) } this.x = 0 } measure() { if (this.hasLabel) { this.label.measure() } } static get shapes() { return { string: SVG.rect, number: SVG.roundedRect, "number-dropdown": SVG.roundedRect, color: SVG.rect, dropdown: SVG.rect, boolean: SVG.pointedRect, stack: SVG.stackRect, reporter: SVG.roundedRect, } } draw(parent) { let w let label if (this.hasLabel) { label = this.label.draw() w = Math.max( 14, this.label.width + (this.shape === "string" || this.shape === "number-dropdown" ? 6 : 9), ) } else { w = this.isInset ? 30 : this.isColor ? 13 : null } if (this.hasArrow) { w += 10 } this.width = w const h = (this.height = this.isRound || this.isColor ? 13 : 14) let el = InputView.shapes[this.shape](w, h) if (this.isColor) { SVG.setProps(el, { fill: this.value, }) } else if (this.isDarker) { el = darkRect(w, h, parent.info.category, el) if (parent.info.color) { SVG.setProps(el, { fill: parent.info.color, }) } } const result = SVG.group([ SVG.setProps(el, { class: `sb-input sb-input-${this.shape}`, }), ]) if (this.hasLabel) { const x = this.isRound ? 5 : 4 result.appendChild(SVG.move(x, 0, label)) } if (this.hasArrow) { const y = this.shape === "dropdown" ? 5 : 4 result.appendChild( SVG.move( w - 10, y, SVG.polygon({ points: [7, 0, 3.5, 4, 0, 0], fill: "#000", opacity: "0.6", }), ), ) } return result } } class BlockView { constructor(block) { Object.assign(this, block) this.children = block.children.map(newView) this.comment = this.comment ? newView(this.comment) : null if ( Object.prototype.hasOwnProperty.call(aliasExtensions, this.info.category) ) { // handle aliases first this.info.category = aliasExtensions[this.info.category] } if ( Object.prototype.hasOwnProperty.call(movedExtensions, this.info.category) ) { this.info.category = movedExtensions[this.info.category] } else if ( Object.prototype.hasOwnProperty.call(extensions, this.info.category) ) { 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.roundedRect, boolean: SVG.pointedRect, hat: SVG.hatRect, cat: SVG.hatRect, "define-hat": SVG.procHatRect, ring: SVG.roundedRect, } } drawSelf(w, h, lines) { // mouths if (lines.length > 1) { return SVG.mouthRect(w, h, this.isFinal, lines, { class: `sb-${this.info.category} sb-bevel`, }) } // outlines if (this.info.shape === "outline") { return SVG.setProps(SVG.stackRect(w, h), { class: "sb-outline", }) } // rings if (this.isRing) { const child = this.children[0] // We use isStack for InputView; isBlock for BlockView; isScript for ScriptView. if (child && (child.isStack || child.isBlock || child.isScript)) { const shape = child.isScript ? "stack" : child.isStack ? child.shape : child.info.shape return SVG.ringRect(w, h, child.y, child.width, child.height, shape, { class: `sb-${this.info.category} sb-bevel`, }) } } const func = BlockView.shapes[this.info.shape] if (!func) { throw new Error(`no shape func: ${this.info.shape}`) } return func(w, h, { class: `sb-${this.info.category} sb-bevel`, }) } minDistance(child) { if (this.isBoolean) { return child.isReporter ? (4 + child.height / 4) | 0 : child.isLabel ? (5 + child.height / 2) | 0 : child.isBoolean || child.shape === "boolean" ? 5 : (2 + child.height / 2) | 0 } if (this.isReporter) { return (child.isInput && child.isRound) || ((child.isReporter || child.isBoolean) && !child.hasScript) ? 0 : child.isLabel ? (2 + child.height / 2) | 0 : (-2 + child.height / 2) | 0 } return 0 } static get padding() { return { hat: [15, 6, 2], cat: [15, 6, 2], "define-hat": [21, 8, 9], reporter: [3, 4, 1], boolean: [3, 4, 2], cap: [6, 6, 2], "c-block": [3, 6, 2], "if-block": [3, 6, 2], ring: [4, 4, 2], null: [4, 6, 2], } } draw() { const isDefine = this.info.shape === "define-hat" let children = this.children const padding = BlockView.padding[this.info.shape] || BlockView.padding.null let pt = padding[0] const px = padding[1] const pb = padding[2] let y = 0 const Line = function (y) { this.y = y this.width = 0 this.height = y ? 13 : 16 this.children = [] } let innerWidth = 0 let scriptWidth = 0 let line = new Line(y) const pushLine = isLast => { if (lines.length === 0) { line.height += pt + pb } else { line.height += isLast ? 0 : +2 line.y -= 1 } 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 = [] for (let i = 0; i < children.length; i++) { const child = children[i] child.el = child.draw(this) if (child.isScript && this.isCommand) { this.hasScript = true pushLine() child.y = y lines.push(child) scriptWidth = Math.max(scriptWidth, Math.max(1, child.width)) child.height = Math.max(12, child.height) + 3 y += child.height line = new Line(y) } else if (child.isArrow) { line.children.push(child) } else { const cmw = i > 0 ? 30 : 0 // 27 const md = this.isCommand ? 0 : this.minDistance(child) const mw = this.isCommand ? child.isBlock || child.isInput ? cmw : 0 : md if (mw && !lines.length && line.width < mw - px) { line.width = mw - px } child.x = line.width line.width += child.width innerWidth = Math.max(innerWidth, line.width + Math.max(0, md - px)) line.width += 4 if (!child.isLabel) { line.height = Math.max(line.height, child.height) } line.children.push(child) } } pushLine(true) innerWidth = Math.max( innerWidth + px * 2, this.isHat || this.hasScript ? 83 : this.isCommand || this.isOutline || this.isRing ? 39 : 20, ) this.height = y this.width = scriptWidth ? Math.max(innerWidth, 15 + scriptWidth) : innerWidth if (isDefine) { const p = Math.min(26, (3.5 + 0.13 * innerWidth) | 0) - 18 this.height += p pt += 2 * p } this.firstLine = lines[0] this.innerWidth = innerWidth const objects = [] for (const line of lines) { if (line.isScript) { objects.push(SVG.move(15, line.y, line.el)) continue } const h = line.height for (const child of line.children) { if (child.isArrow) { objects.push(SVG.move(innerWidth - 15, this.height - 3, child.el)) continue } let y = pt + (h - child.height - pt - pb) / 2 - 1 if (isDefine && child.isLabel) { y += 3 } else if (child.isIcon) { y += child.dy | 0 } if (this.isRing) { child.y = (line.y + y) | 0 if (child.isInset) { continue } } objects.push(SVG.move(px + child.x, (line.y + y) | 0, child.el)) if (child.diff === "+") { const ellipse = SVG.insEllipse(child.width, child.height) objects.push(SVG.move(px + child.x, (line.y + y) | 0, ellipse)) } } } const el = this.drawSelf(innerWidth, this.height, lines) objects.splice(0, 0, el) if (this.info.color) { SVG.setProps(el, { fill: this.info.color, }) } return SVG.group(objects) } } 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() { const labelEl = this.label.draw() this.width = this.label.width + 16 return SVG.group([ SVG.commentLine(this.hasBlock ? CommentView.lineLength : 0, 6), SVG.commentRect(this.width, this.height, { class: "sb-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: "sb-diff sb-diff-ins", }) } // TODO how can we always raise Glows above their parents? draw() { const c = this.child const el = c.isScript ? c.draw(true) : c.draw() 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(inside) { const children = [] let y = 0 this.width = 0 for (const block of this.blocks) { const x = inside ? 0 : 2 const child = block.draw() 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() children.push(SVG.move(cx, cy - comment.height / 2, el)) this.width = Math.max(this.width, cx + comment.width) } } this.height = y if (!inside && !this.isFinal) { this.height += 3 } const lastBlock = this.blocks[this.blocks.length - 1] if (!inside && lastBlock.isGlow) { this.height += 2 // 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 } 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 (const script of this.scripts) { if (height) { height += 10 } script.y = height elements.push(SVG.move(0, height, script.draw())) height += script.height width = Math.max(width, script.width + 4) } this.width = width this.height = height // return SVG const svg = SVG.newSVG(width, height, this.scale) svg.appendChild( (this.defs = SVG.withChildren(SVG.el("defs"), [ bevelFilter("bevelFilter", false), bevelFilter("inputBevelFilter", true), darkFilter("inputDarkFilter"), ...makeIcons(), ])), ) svg.appendChild(SVG.group(elements)) 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)