Skip to content

Implement canvas-based TextBubbleSkin #451

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/RenderWebGL.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const PenSkin = require('./PenSkin');
const RenderConstants = require('./RenderConstants');
const ShaderManager = require('./ShaderManager');
const SVGSkin = require('./SVGSkin');
const SVGTextBubble = require('./util/svg-text-bubble');
const TextBubbleSkin = require('./TextBubbleSkin');
const EffectTransform = require('./EffectTransform');
const log = require('./util/log');

Expand Down Expand Up @@ -184,8 +184,6 @@ class RenderWebGL extends EventEmitter {
/** @type {Array.<snapshotCallback>} */
this._snapshotCallbacks = [];

this._svgTextBubble = new SVGTextBubble();

this._createGeometry();

this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
Expand Down Expand Up @@ -343,8 +341,11 @@ class RenderWebGL extends EventEmitter {
* @returns {!int} the ID for the new skin.
*/
createTextSkin (type, text, pointsLeft) {
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
return this.createSVGSkin(bubbleSvg, [0, 0]);
const skinId = this._nextSkinId++;
const newSkin = new TextBubbleSkin(skinId, this);
newSkin.setTextBubble(type, text, pointsLeft);
this._allSkins[skinId] = newSkin;
return skinId;
}

/**
Expand Down Expand Up @@ -407,8 +408,14 @@ class RenderWebGL extends EventEmitter {
* @param {!boolean} pointsLeft - which side the bubble is pointing.
*/
updateTextSkin (skinId, type, text, pointsLeft) {
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
this.updateSVGSkin(skinId, bubbleSvg, [0, 0]);
if (this._allSkins[skinId] instanceof TextBubbleSkin) {
this._allSkins[skinId].setTextBubble(type, text, pointsLeft);
return;
}

const newSkin = new TextBubbleSkin(skinId, this);
newSkin.setTextBubble(type, text, pointsLeft);
this._reskin(skinId, newSkin);
}


Expand Down
287 changes: 287 additions & 0 deletions src/TextBubbleSkin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
const twgl = require('twgl.js');

const TextWrapper = require('./util/text-wrapper');
const CanvasMeasurementProvider = require('./util/canvas-measurement-provider');
const Skin = require('./Skin');

const BubbleStyle = {
MAX_LINE_WIDTH: 170, // Maximum width, in Scratch pixels, of a single line of text

MIN_WIDTH: 50, // Minimum width, in Scratch pixels, of a text bubble
STROKE_WIDTH: 4, // Thickness of the stroke around the bubble. Only half's visible because it's drawn under the fill
PADDING: 10, // Padding around the text area
CORNER_RADIUS: 16, // Radius of the rounded corners
TAIL_HEIGHT: 12, // Height of the speech bubble's "tail". Probably should be a constant.

FONT: 'Helvetica', // Font to render the text with
FONT_SIZE: 14, // Font size, in Scratch pixels
FONT_HEIGHT_RATIO: 0.9, // Height, in Scratch pixels, of the text, as a proportion of the font's size
LINE_HEIGHT: 16, // Spacing between each line of text

COLORS: {
BUBBLE_FILL: 'white',
BUBBLE_STROKE: 'rgba(0, 0, 0, 0.15)',
TEXT_FILL: '#575E75'
}
};

class TextBubbleSkin extends Skin {
/**
* Create a new text bubble skin.
* @param {!int} id - The ID for this Skin.
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
* @constructor
* @extends Skin
*/
constructor (id, renderer) {
super(id);

/** @type {RenderWebGL} */
this._renderer = renderer;

/** @type {HTMLCanvasElement} */
this._canvas = document.createElement('canvas');

/** @type {WebGLTexture} */
this._texture = null;

/** @type {Array<number>} */
this._size = [0, 0];

/** @type {number} */
this._renderedScale = 0;

/** @type {Array<string>} */
this._lines = [];

this._textSize = {width: 0, height: 0};
this._textAreaSize = {width: 0, height: 0};

/** @type {string} */
this._bubbleType = '';

/** @type {boolean} */
this._pointsLeft = false;

/** @type {boolean} */
this._textDirty = true;

/** @type {boolean} */
this._textureDirty = true;

this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d'));
this.textWrapper = new TextWrapper(this.measurementProvider);

this._restyleCanvas();
}

/**
* Dispose of this object. Do not use it after calling this method.
*/
dispose () {
if (this._texture) {
this._renderer.gl.deleteTexture(this._texture);
this._texture = null;
}
this._canvas = null;
super.dispose();
}

/**
* @return {Array<number>} the dimensions, in Scratch units, of this skin.
*/
get size () {
if (this._textDirty) {
this._reflowLines();
}
return this._size;
}

/**
* Set parameters for this text bubble.
* @param {!string} type - either "say" or "think".
* @param {!string} text - the text for the bubble.
* @param {!boolean} pointsLeft - which side the bubble is pointing.
*/
setTextBubble (type, text, pointsLeft) {
this._text = text;
this._bubbleType = type;
this._pointsLeft = pointsLeft;

this._textDirty = true;
this._textureDirty = true;
this.emit(Skin.Events.WasAltered);
}

/**
* Re-style the canvas after resizing it. This is necessary to ensure proper text measurement.
*/
_restyleCanvas () {
this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
}

/**
* Update the array of wrapped lines and the text dimensions.
*/
_reflowLines () {
this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text);

// Measure width of longest line to avoid extra-wide bubbles
let longestLine = 0;
for (const line of this._lines) {
longestLine = Math.max(longestLine, this.measurementProvider.measureText(line));
}

this._textSize.width = longestLine;
this._textSize.height = BubbleStyle.LINE_HEIGHT * this._lines.length;

// Calculate the canvas-space sizes of the padded text area and full text bubble
const paddedWidth = Math.max(this._textSize.width, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2);
const paddedHeight = this._textSize.height + (BubbleStyle.PADDING * 2);

this._textAreaSize.width = paddedWidth;
this._textAreaSize.height = paddedHeight;

this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH;
this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT;

this._textDirty = false;
}

/**
* Render this text bubble at a certain scale, using the current parameters, to the canvas.
* @param {number} scale The scale to render the bubble at
*/
_renderTextBubble (scale) {
const ctx = this._canvas.getContext('2d');

if (this._textDirty) {
this._reflowLines();
}

// Calculate the canvas-space sizes of the padded text area and full text bubble
const paddedWidth = this._textAreaSize.width;
const paddedHeight = this._textAreaSize.height;

// Resize the canvas to the correct screen-space size
this._canvas.width = Math.ceil(this._size[0] * scale);
this._canvas.height = Math.ceil(this._size[1] * scale);
this._restyleCanvas();

// Reset the transform before clearing to ensure 100% clearage
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);

ctx.scale(scale, scale);
ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5);

// If the text bubble points leftward, flip the canvas
ctx.save();
if (this._pointsLeft) {
ctx.scale(-1, 1);
ctx.translate(-paddedWidth, 0);
}

// Draw the bubble's rounded borders
ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight);
ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS);
ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS);
ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS);
ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight,
BubbleStyle.CORNER_RADIUS);

// Translate the canvas so we don't have to do a bunch of width/height arithmetic
ctx.save();
ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight);

// Draw the bubble's "tail"
if (this._bubbleType === 'say') {
// For a speech bubble, draw one swoopy thing
ctx.bezierCurveTo(0, 4, 4, 8, 4, 10);
ctx.arcTo(4, 12, 2, 12, 2);
ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0);

ctx.closePath();
} else {
// For a thinking bubble, draw a partial circle attached to the bubble...
ctx.arc(-16, 0, 4, 0, Math.PI);

ctx.closePath();

// and two circles detached from it
ctx.moveTo(-7, 7.25);
ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2);

ctx.moveTo(0, 9.5);
ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2);
}

// Un-translate the canvas and fill + stroke the text bubble
ctx.restore();

ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL;
ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE;
ctx.lineWidth = BubbleStyle.STROKE_WIDTH;

ctx.stroke();
ctx.fill();

// Un-flip the canvas if it was flipped
ctx.restore();

// Draw each line of text
ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL;
ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
const lines = this._lines;
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber];
ctx.fillText(
line,
BubbleStyle.PADDING,
BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) +
(BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE)
);
}

this._renderedScale = scale;
}

/**
* @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range.
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
*/
getTexture (scale) {
// The texture only ever gets uniform scale. Take the larger of the two axes.
const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100;
const requestedScale = scaleMax / 100;

// If we already rendered the text bubble at this scale, we can skip re-rendering it.
if (this._textureDirty || this._renderedScale !== requestedScale) {
this._renderTextBubble(requestedScale);
this._textureDirty = false;

const context = this._canvas.getContext('2d');
const textureData = context.getImageData(0, 0, this._canvas.width, this._canvas.height);

const gl = this._renderer.gl;

if (this._texture === null) {
const textureOptions = {
auto: true,
wrap: gl.CLAMP_TO_EDGE,
src: textureData
};

this._texture = twgl.createTexture(gl, textureOptions);
}

gl.bindTexture(gl.TEXTURE_2D, this._texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
this._silhouette.update(textureData);
}

return this._texture;
}
}

module.exports = TextBubbleSkin;
41 changes: 41 additions & 0 deletions src/util/canvas-measurement-provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class CanvasMeasurementProvider {
/**
* @param {CanvasRenderingContext2D} ctx - provides a canvas rendering context
* with 'font' set to the text style of the text to be wrapped.
*/
constructor (ctx) {
this._ctx = ctx;
this._cache = {};
}


// We don't need to set up or tear down anything here. Should these be removed altogether?

/**
* Called by the TextWrapper before a batch of zero or more calls to measureText().
*/
beginMeasurementSession () {

}

/**
* Called by the TextWrapper after a batch of zero or more calls to measureText().
*/
endMeasurementSession () {

}

/**
* Measure a whole string as one unit.
* @param {string} text - the text to measure.
* @returns {number} - the length of the string.
*/
measureText (text) {
if (!this._cache[text]) {
this._cache[text] = this._ctx.measureText(text).width;
}
return this._cache[text];
}
}

module.exports = CanvasMeasurementProvider;
Loading