Skip to content

Commit e98fb37

Browse files
authored
Merge pull request #451 from adroitwhiz/canvas-text-bubble
Implement canvas-based TextBubbleSkin
2 parents 5ef4ae6 + c313f6a commit e98fb37

File tree

7 files changed

+344
-353
lines changed

7 files changed

+344
-353
lines changed

src/RenderWebGL.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const PenSkin = require('./PenSkin');
1010
const RenderConstants = require('./RenderConstants');
1111
const ShaderManager = require('./ShaderManager');
1212
const SVGSkin = require('./SVGSkin');
13-
const SVGTextBubble = require('./util/svg-text-bubble');
13+
const TextBubbleSkin = require('./TextBubbleSkin');
1414
const EffectTransform = require('./EffectTransform');
1515
const log = require('./util/log');
1616

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

187-
this._svgTextBubble = new SVGTextBubble();
188-
189187
this._createGeometry();
190188

191189
this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
@@ -343,8 +341,11 @@ class RenderWebGL extends EventEmitter {
343341
* @returns {!int} the ID for the new skin.
344342
*/
345343
createTextSkin (type, text, pointsLeft) {
346-
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
347-
return this.createSVGSkin(bubbleSvg, [0, 0]);
344+
const skinId = this._nextSkinId++;
345+
const newSkin = new TextBubbleSkin(skinId, this);
346+
newSkin.setTextBubble(type, text, pointsLeft);
347+
this._allSkins[skinId] = newSkin;
348+
return skinId;
348349
}
349350

350351
/**
@@ -407,8 +408,14 @@ class RenderWebGL extends EventEmitter {
407408
* @param {!boolean} pointsLeft - which side the bubble is pointing.
408409
*/
409410
updateTextSkin (skinId, type, text, pointsLeft) {
410-
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
411-
this.updateSVGSkin(skinId, bubbleSvg, [0, 0]);
411+
if (this._allSkins[skinId] instanceof TextBubbleSkin) {
412+
this._allSkins[skinId].setTextBubble(type, text, pointsLeft);
413+
return;
414+
}
415+
416+
const newSkin = new TextBubbleSkin(skinId, this);
417+
newSkin.setTextBubble(type, text, pointsLeft);
418+
this._reskin(skinId, newSkin);
412419
}
413420

414421

src/TextBubbleSkin.js

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
const twgl = require('twgl.js');
2+
3+
const TextWrapper = require('./util/text-wrapper');
4+
const CanvasMeasurementProvider = require('./util/canvas-measurement-provider');
5+
const Skin = require('./Skin');
6+
7+
const BubbleStyle = {
8+
MAX_LINE_WIDTH: 170, // Maximum width, in Scratch pixels, of a single line of text
9+
10+
MIN_WIDTH: 50, // Minimum width, in Scratch pixels, of a text bubble
11+
STROKE_WIDTH: 4, // Thickness of the stroke around the bubble. Only half's visible because it's drawn under the fill
12+
PADDING: 10, // Padding around the text area
13+
CORNER_RADIUS: 16, // Radius of the rounded corners
14+
TAIL_HEIGHT: 12, // Height of the speech bubble's "tail". Probably should be a constant.
15+
16+
FONT: 'Helvetica', // Font to render the text with
17+
FONT_SIZE: 14, // Font size, in Scratch pixels
18+
FONT_HEIGHT_RATIO: 0.9, // Height, in Scratch pixels, of the text, as a proportion of the font's size
19+
LINE_HEIGHT: 16, // Spacing between each line of text
20+
21+
COLORS: {
22+
BUBBLE_FILL: 'white',
23+
BUBBLE_STROKE: 'rgba(0, 0, 0, 0.15)',
24+
TEXT_FILL: '#575E75'
25+
}
26+
};
27+
28+
class TextBubbleSkin extends Skin {
29+
/**
30+
* Create a new text bubble skin.
31+
* @param {!int} id - The ID for this Skin.
32+
* @param {!RenderWebGL} renderer - The renderer which will use this skin.
33+
* @constructor
34+
* @extends Skin
35+
*/
36+
constructor (id, renderer) {
37+
super(id);
38+
39+
/** @type {RenderWebGL} */
40+
this._renderer = renderer;
41+
42+
/** @type {HTMLCanvasElement} */
43+
this._canvas = document.createElement('canvas');
44+
45+
/** @type {WebGLTexture} */
46+
this._texture = null;
47+
48+
/** @type {Array<number>} */
49+
this._size = [0, 0];
50+
51+
/** @type {number} */
52+
this._renderedScale = 0;
53+
54+
/** @type {Array<string>} */
55+
this._lines = [];
56+
57+
this._textSize = {width: 0, height: 0};
58+
this._textAreaSize = {width: 0, height: 0};
59+
60+
/** @type {string} */
61+
this._bubbleType = '';
62+
63+
/** @type {boolean} */
64+
this._pointsLeft = false;
65+
66+
/** @type {boolean} */
67+
this._textDirty = true;
68+
69+
/** @type {boolean} */
70+
this._textureDirty = true;
71+
72+
this.measurementProvider = new CanvasMeasurementProvider(this._canvas.getContext('2d'));
73+
this.textWrapper = new TextWrapper(this.measurementProvider);
74+
75+
this._restyleCanvas();
76+
}
77+
78+
/**
79+
* Dispose of this object. Do not use it after calling this method.
80+
*/
81+
dispose () {
82+
if (this._texture) {
83+
this._renderer.gl.deleteTexture(this._texture);
84+
this._texture = null;
85+
}
86+
this._canvas = null;
87+
super.dispose();
88+
}
89+
90+
/**
91+
* @return {Array<number>} the dimensions, in Scratch units, of this skin.
92+
*/
93+
get size () {
94+
if (this._textDirty) {
95+
this._reflowLines();
96+
}
97+
return this._size;
98+
}
99+
100+
/**
101+
* Set parameters for this text bubble.
102+
* @param {!string} type - either "say" or "think".
103+
* @param {!string} text - the text for the bubble.
104+
* @param {!boolean} pointsLeft - which side the bubble is pointing.
105+
*/
106+
setTextBubble (type, text, pointsLeft) {
107+
this._text = text;
108+
this._bubbleType = type;
109+
this._pointsLeft = pointsLeft;
110+
111+
this._textDirty = true;
112+
this._textureDirty = true;
113+
this.emit(Skin.Events.WasAltered);
114+
}
115+
116+
/**
117+
* Re-style the canvas after resizing it. This is necessary to ensure proper text measurement.
118+
*/
119+
_restyleCanvas () {
120+
this._canvas.getContext('2d').font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
121+
}
122+
123+
/**
124+
* Update the array of wrapped lines and the text dimensions.
125+
*/
126+
_reflowLines () {
127+
this._lines = this.textWrapper.wrapText(BubbleStyle.MAX_LINE_WIDTH, this._text);
128+
129+
// Measure width of longest line to avoid extra-wide bubbles
130+
let longestLine = 0;
131+
for (const line of this._lines) {
132+
longestLine = Math.max(longestLine, this.measurementProvider.measureText(line));
133+
}
134+
135+
this._textSize.width = longestLine;
136+
this._textSize.height = BubbleStyle.LINE_HEIGHT * this._lines.length;
137+
138+
// Calculate the canvas-space sizes of the padded text area and full text bubble
139+
const paddedWidth = Math.max(this._textSize.width, BubbleStyle.MIN_WIDTH) + (BubbleStyle.PADDING * 2);
140+
const paddedHeight = this._textSize.height + (BubbleStyle.PADDING * 2);
141+
142+
this._textAreaSize.width = paddedWidth;
143+
this._textAreaSize.height = paddedHeight;
144+
145+
this._size[0] = paddedWidth + BubbleStyle.STROKE_WIDTH;
146+
this._size[1] = paddedHeight + BubbleStyle.STROKE_WIDTH + BubbleStyle.TAIL_HEIGHT;
147+
148+
this._textDirty = false;
149+
}
150+
151+
/**
152+
* Render this text bubble at a certain scale, using the current parameters, to the canvas.
153+
* @param {number} scale The scale to render the bubble at
154+
*/
155+
_renderTextBubble (scale) {
156+
const ctx = this._canvas.getContext('2d');
157+
158+
if (this._textDirty) {
159+
this._reflowLines();
160+
}
161+
162+
// Calculate the canvas-space sizes of the padded text area and full text bubble
163+
const paddedWidth = this._textAreaSize.width;
164+
const paddedHeight = this._textAreaSize.height;
165+
166+
// Resize the canvas to the correct screen-space size
167+
this._canvas.width = Math.ceil(this._size[0] * scale);
168+
this._canvas.height = Math.ceil(this._size[1] * scale);
169+
this._restyleCanvas();
170+
171+
// Reset the transform before clearing to ensure 100% clearage
172+
ctx.setTransform(1, 0, 0, 1, 0, 0);
173+
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
174+
175+
ctx.scale(scale, scale);
176+
ctx.translate(BubbleStyle.STROKE_WIDTH * 0.5, BubbleStyle.STROKE_WIDTH * 0.5);
177+
178+
// If the text bubble points leftward, flip the canvas
179+
ctx.save();
180+
if (this._pointsLeft) {
181+
ctx.scale(-1, 1);
182+
ctx.translate(-paddedWidth, 0);
183+
}
184+
185+
// Draw the bubble's rounded borders
186+
ctx.moveTo(BubbleStyle.CORNER_RADIUS, paddedHeight);
187+
ctx.arcTo(0, paddedHeight, 0, paddedHeight - BubbleStyle.CORNER_RADIUS, BubbleStyle.CORNER_RADIUS);
188+
ctx.arcTo(0, 0, paddedWidth, 0, BubbleStyle.CORNER_RADIUS);
189+
ctx.arcTo(paddedWidth, 0, paddedWidth, paddedHeight, BubbleStyle.CORNER_RADIUS);
190+
ctx.arcTo(paddedWidth, paddedHeight, paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight,
191+
BubbleStyle.CORNER_RADIUS);
192+
193+
// Translate the canvas so we don't have to do a bunch of width/height arithmetic
194+
ctx.save();
195+
ctx.translate(paddedWidth - BubbleStyle.CORNER_RADIUS, paddedHeight);
196+
197+
// Draw the bubble's "tail"
198+
if (this._bubbleType === 'say') {
199+
// For a speech bubble, draw one swoopy thing
200+
ctx.bezierCurveTo(0, 4, 4, 8, 4, 10);
201+
ctx.arcTo(4, 12, 2, 12, 2);
202+
ctx.bezierCurveTo(-1, 12, -11, 8, -16, 0);
203+
204+
ctx.closePath();
205+
} else {
206+
// For a thinking bubble, draw a partial circle attached to the bubble...
207+
ctx.arc(-16, 0, 4, 0, Math.PI);
208+
209+
ctx.closePath();
210+
211+
// and two circles detached from it
212+
ctx.moveTo(-7, 7.25);
213+
ctx.arc(-9.25, 7.25, 2.25, 0, Math.PI * 2);
214+
215+
ctx.moveTo(0, 9.5);
216+
ctx.arc(-1.5, 9.5, 1.5, 0, Math.PI * 2);
217+
}
218+
219+
// Un-translate the canvas and fill + stroke the text bubble
220+
ctx.restore();
221+
222+
ctx.fillStyle = BubbleStyle.COLORS.BUBBLE_FILL;
223+
ctx.strokeStyle = BubbleStyle.COLORS.BUBBLE_STROKE;
224+
ctx.lineWidth = BubbleStyle.STROKE_WIDTH;
225+
226+
ctx.stroke();
227+
ctx.fill();
228+
229+
// Un-flip the canvas if it was flipped
230+
ctx.restore();
231+
232+
// Draw each line of text
233+
ctx.fillStyle = BubbleStyle.COLORS.TEXT_FILL;
234+
ctx.font = `${BubbleStyle.FONT_SIZE}px ${BubbleStyle.FONT}, sans-serif`;
235+
const lines = this._lines;
236+
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
237+
const line = lines[lineNumber];
238+
ctx.fillText(
239+
line,
240+
BubbleStyle.PADDING,
241+
BubbleStyle.PADDING + (BubbleStyle.LINE_HEIGHT * lineNumber) +
242+
(BubbleStyle.FONT_HEIGHT_RATIO * BubbleStyle.FONT_SIZE)
243+
);
244+
}
245+
246+
this._renderedScale = scale;
247+
}
248+
249+
/**
250+
* @param {Array<number>} scale - The scaling factors to be used, each in the [0,100] range.
251+
* @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale.
252+
*/
253+
getTexture (scale) {
254+
// The texture only ever gets uniform scale. Take the larger of the two axes.
255+
const scaleMax = scale ? Math.max(Math.abs(scale[0]), Math.abs(scale[1])) : 100;
256+
const requestedScale = scaleMax / 100;
257+
258+
// If we already rendered the text bubble at this scale, we can skip re-rendering it.
259+
if (this._textureDirty || this._renderedScale !== requestedScale) {
260+
this._renderTextBubble(requestedScale);
261+
this._textureDirty = false;
262+
263+
const context = this._canvas.getContext('2d');
264+
const textureData = context.getImageData(0, 0, this._canvas.width, this._canvas.height);
265+
266+
const gl = this._renderer.gl;
267+
268+
if (this._texture === null) {
269+
const textureOptions = {
270+
auto: true,
271+
wrap: gl.CLAMP_TO_EDGE,
272+
src: textureData
273+
};
274+
275+
this._texture = twgl.createTexture(gl, textureOptions);
276+
}
277+
278+
gl.bindTexture(gl.TEXTURE_2D, this._texture);
279+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
280+
this._silhouette.update(textureData);
281+
}
282+
283+
return this._texture;
284+
}
285+
}
286+
287+
module.exports = TextBubbleSkin;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class CanvasMeasurementProvider {
2+
/**
3+
* @param {CanvasRenderingContext2D} ctx - provides a canvas rendering context
4+
* with 'font' set to the text style of the text to be wrapped.
5+
*/
6+
constructor (ctx) {
7+
this._ctx = ctx;
8+
this._cache = {};
9+
}
10+
11+
12+
// We don't need to set up or tear down anything here. Should these be removed altogether?
13+
14+
/**
15+
* Called by the TextWrapper before a batch of zero or more calls to measureText().
16+
*/
17+
beginMeasurementSession () {
18+
19+
}
20+
21+
/**
22+
* Called by the TextWrapper after a batch of zero or more calls to measureText().
23+
*/
24+
endMeasurementSession () {
25+
26+
}
27+
28+
/**
29+
* Measure a whole string as one unit.
30+
* @param {string} text - the text to measure.
31+
* @returns {number} - the length of the string.
32+
*/
33+
measureText (text) {
34+
if (!this._cache[text]) {
35+
this._cache[text] = this._ctx.measureText(text).width;
36+
}
37+
return this._cache[text];
38+
}
39+
}
40+
41+
module.exports = CanvasMeasurementProvider;

0 commit comments

Comments
 (0)