mirror of
https://github.com/scratchfoundation/scratch-render.git
synced 2025-08-28 22:30:04 -04:00
Canvas-based TextBubbleSkin
This commit is contained in:
parent
4a55d63ada
commit
1021877ba6
9 changed files with 377 additions and 343 deletions
|
@ -329,6 +329,11 @@ class Drawable {
|
||||||
scaledSize[1] = skinSize[1] * this._scale[1] / 100;
|
scaledSize[1] = skinSize[1] * this._scale[1] / 100;
|
||||||
// scaledSize[2] = 0;
|
// scaledSize[2] = 0;
|
||||||
|
|
||||||
|
if (this.skin.roundBounds) {
|
||||||
|
scaledSize[0] = Math.ceil(scaledSize[0]);
|
||||||
|
scaledSize[1] = Math.ceil(scaledSize[1]);
|
||||||
|
}
|
||||||
|
|
||||||
this._skinScaleDirty = false;
|
this._skinScaleDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ const PenSkin = require('./PenSkin');
|
||||||
const RenderConstants = require('./RenderConstants');
|
const RenderConstants = require('./RenderConstants');
|
||||||
const ShaderManager = require('./ShaderManager');
|
const ShaderManager = require('./ShaderManager');
|
||||||
const SVGSkin = require('./SVGSkin');
|
const SVGSkin = require('./SVGSkin');
|
||||||
const SVGTextBubble = require('./util/svg-text-bubble');
|
const TextBubbleSkin = require('./TextBubbleSkin');
|
||||||
const EffectTransform = require('./EffectTransform');
|
const EffectTransform = require('./EffectTransform');
|
||||||
const log = require('./util/log');
|
const log = require('./util/log');
|
||||||
|
|
||||||
|
@ -184,8 +184,6 @@ class RenderWebGL extends EventEmitter {
|
||||||
/** @type {Array.<snapshotCallback>} */
|
/** @type {Array.<snapshotCallback>} */
|
||||||
this._snapshotCallbacks = [];
|
this._snapshotCallbacks = [];
|
||||||
|
|
||||||
this._svgTextBubble = new SVGTextBubble();
|
|
||||||
|
|
||||||
this._createGeometry();
|
this._createGeometry();
|
||||||
|
|
||||||
this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
|
this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
|
||||||
|
@ -343,8 +341,11 @@ class RenderWebGL extends EventEmitter {
|
||||||
* @returns {!int} the ID for the new skin.
|
* @returns {!int} the ID for the new skin.
|
||||||
*/
|
*/
|
||||||
createTextSkin (type, text, pointsLeft) {
|
createTextSkin (type, text, pointsLeft) {
|
||||||
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
|
const skinId = this._nextSkinId++;
|
||||||
return this.createSVGSkin(bubbleSvg, [0, 0]);
|
const newSkin = new TextBubbleSkin(skinId, this);
|
||||||
|
newSkin.setTextBubble(type, text, pointsLeft);
|
||||||
|
this._allSkins[skinId] = newSkin;
|
||||||
|
return skinId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -407,8 +408,14 @@ class RenderWebGL extends EventEmitter {
|
||||||
* @param {!boolean} pointsLeft - which side the bubble is pointing.
|
* @param {!boolean} pointsLeft - which side the bubble is pointing.
|
||||||
*/
|
*/
|
||||||
updateTextSkin (skinId, type, text, pointsLeft) {
|
updateTextSkin (skinId, type, text, pointsLeft) {
|
||||||
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
|
if (this._allSkins[skinId] instanceof TextBubbleSkin) {
|
||||||
this.updateSVGSkin(skinId, bubbleSvg, [0, 0]);
|
this._allSkins[skinId].setTextBubble(type, text, pointsLeft);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSkin = new TextBubbleSkin(skinId, this);
|
||||||
|
newSkin.setTextBubble(type, text, pointsLeft);
|
||||||
|
this._reskin(skinId, newSkin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -76,6 +76,15 @@ class Skin extends EventEmitter {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean} whether this skin's pixel bounds should be rounded up to the nearest screen-space pixel.
|
||||||
|
* Useful for skins always rendered in screen-space, e.g. text bubbles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
get roundBounds () {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {boolean} true if alpha is premultiplied, false otherwise
|
* @returns {boolean} true if alpha is premultiplied, false otherwise
|
||||||
*/
|
*/
|
||||||
|
|
296
src/TextBubbleSkin.js
Normal file
296
src/TextBubbleSkin.js
Normal file
|
@ -0,0 +1,296 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean} whether this skin's pixel bounds should be rounded up to the nearest screen-space pixel.
|
||||||
|
* Useful for skins always rendered in screen-space, e.g. text bubbles.
|
||||||
|
*/
|
||||||
|
get roundBounds () {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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.
|
||||||
|
* @param {string} text - The text to wrap.
|
||||||
|
*/
|
||||||
|
_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;
|
|
@ -6,6 +6,8 @@ var fudge = 90;
|
||||||
var renderer = new ScratchRender(canvas);
|
var renderer = new ScratchRender(canvas);
|
||||||
renderer.setLayerGroupOrdering(['group1']);
|
renderer.setLayerGroupOrdering(['group1']);
|
||||||
|
|
||||||
|
window.renderer = renderer;
|
||||||
|
|
||||||
var drawableID = renderer.createDrawable('group1');
|
var drawableID = renderer.createDrawable('group1');
|
||||||
renderer.updateDrawableProperties(drawableID, {
|
renderer.updateDrawableProperties(drawableID, {
|
||||||
position: [0, 0],
|
position: [0, 0],
|
||||||
|
@ -15,12 +17,13 @@ renderer.updateDrawableProperties(drawableID, {
|
||||||
|
|
||||||
var drawableID2 = renderer.createDrawable('group1');
|
var drawableID2 = renderer.createDrawable('group1');
|
||||||
var wantBitmapSkin = false;
|
var wantBitmapSkin = false;
|
||||||
|
var wantTextSkin = true;
|
||||||
|
|
||||||
// Bitmap (squirrel)
|
// Bitmap (squirrel)
|
||||||
var image = new Image();
|
var image = new Image();
|
||||||
image.addEventListener('load', () => {
|
image.addEventListener('load', () => {
|
||||||
var bitmapSkinId = renderer.createBitmapSkin(image);
|
var bitmapSkinId = renderer.createBitmapSkin(image);
|
||||||
if (wantBitmapSkin) {
|
if (wantBitmapSkin && !wantTextSkin) {
|
||||||
renderer.updateDrawableProperties(drawableID2, {
|
renderer.updateDrawableProperties(drawableID2, {
|
||||||
skinId: bitmapSkinId
|
skinId: bitmapSkinId
|
||||||
});
|
});
|
||||||
|
@ -33,7 +36,7 @@ image.src = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.addEventListener('load', function () {
|
xhr.addEventListener('load', function () {
|
||||||
var skinId = renderer.createSVGSkin(xhr.responseText);
|
var skinId = renderer.createSVGSkin(xhr.responseText);
|
||||||
if (!wantBitmapSkin) {
|
if (!wantBitmapSkin && !wantTextSkin) {
|
||||||
renderer.updateDrawableProperties(drawableID2, {
|
renderer.updateDrawableProperties(drawableID2, {
|
||||||
skinId: skinId
|
skinId: skinId
|
||||||
});
|
});
|
||||||
|
@ -42,6 +45,11 @@ xhr.addEventListener('load', function () {
|
||||||
xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/f88bf1935daea28f8ca098462a31dbb0.svg/get/');
|
xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/f88bf1935daea28f8ca098462a31dbb0.svg/get/');
|
||||||
xhr.send();
|
xhr.send();
|
||||||
|
|
||||||
|
var skinId = renderer.createTextSkin("think", "testing text bubble", false);
|
||||||
|
renderer.updateDrawableProperties(drawableID2, {
|
||||||
|
skinId: skinId
|
||||||
|
});
|
||||||
|
|
||||||
var posX = 0;
|
var posX = 0;
|
||||||
var posY = 0;
|
var posY = 0;
|
||||||
var scaleX = 100;
|
var scaleX = 100;
|
||||||
|
@ -81,7 +89,7 @@ const handleFudgeChanged = function (event) {
|
||||||
props.direction = fudge;
|
props.direction = fudge;
|
||||||
break;
|
break;
|
||||||
case 'scalex':
|
case 'scalex':
|
||||||
props.scale = [fudge, scaleY];
|
props.scale = [fudge, fudge];
|
||||||
scaleX = fudge;
|
scaleX = fudge;
|
||||||
break;
|
break;
|
||||||
case 'scaley':
|
case 'scaley':
|
||||||
|
|
41
src/util/canvas-measurement-provider.js
Normal file
41
src/util/canvas-measurement-provider.js
Normal file
|
@ -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;
|
|
@ -1,205 +0,0 @@
|
||||||
const SVGTextWrapper = require('./svg-text-wrapper');
|
|
||||||
const SvgRenderer = require('scratch-svg-renderer').SVGRenderer;
|
|
||||||
|
|
||||||
const MAX_LINE_LENGTH = 170;
|
|
||||||
const MIN_WIDTH = 50;
|
|
||||||
const STROKE_WIDTH = 4;
|
|
||||||
|
|
||||||
class SVGTextBubble {
|
|
||||||
constructor () {
|
|
||||||
this.svgRenderer = new SvgRenderer();
|
|
||||||
this.svgTextWrapper = new SVGTextWrapper(this.makeSvgTextElement);
|
|
||||||
this._textSizeCache = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return {SVGElement} an SVG text node with the properties that we want for speech bubbles.
|
|
||||||
*/
|
|
||||||
makeSvgTextElement () {
|
|
||||||
const svgText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
||||||
svgText.setAttribute('alignment-baseline', 'text-before-edge');
|
|
||||||
svgText.setAttribute('font-size', '14');
|
|
||||||
svgText.setAttribute('fill', '#575E75');
|
|
||||||
// TODO Do we want to use the new default sans font instead of Helvetica?
|
|
||||||
svgText.setAttribute('font-family', 'Helvetica');
|
|
||||||
return svgText;
|
|
||||||
}
|
|
||||||
|
|
||||||
_speechBubble (w, h, radius, pointsLeft) {
|
|
||||||
let pathString = `
|
|
||||||
M 0 ${radius}
|
|
||||||
A ${radius} ${radius} 0 0 1 ${radius} 0
|
|
||||||
L ${w - radius} 0
|
|
||||||
A ${radius} ${radius} 0 0 1 ${w} ${radius}
|
|
||||||
L ${w} ${h - radius}
|
|
||||||
A ${radius} ${radius} 0 0 1 ${w - radius} ${h}`;
|
|
||||||
|
|
||||||
if (pointsLeft) {
|
|
||||||
pathString += `
|
|
||||||
L 32 ${h}
|
|
||||||
c -5 8 -15 12 -18 12
|
|
||||||
a 2 2 0 0 1 -2 -2
|
|
||||||
c 0 -2 4 -6 4 -10`;
|
|
||||||
} else {
|
|
||||||
pathString += `
|
|
||||||
L ${w - 16} ${h}
|
|
||||||
c 0 4 4 8 4 10
|
|
||||||
a 2 2 0 0 1 -2 2
|
|
||||||
c -3 0 -13 -4 -18 -12`;
|
|
||||||
}
|
|
||||||
|
|
||||||
pathString += `
|
|
||||||
L ${radius} ${h}
|
|
||||||
A ${radius} ${radius} 0 0 1 0 ${h - radius}
|
|
||||||
Z`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d="${pathString}"
|
|
||||||
stroke="rgba(0, 0, 0, 0.15)"
|
|
||||||
stroke-width="${STROKE_WIDTH}"
|
|
||||||
fill="rgba(0, 0, 0, 0.15)"
|
|
||||||
stroke-line-join="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="${pathString}"
|
|
||||||
stroke="none"
|
|
||||||
fill="white" />
|
|
||||||
</g>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_thinkBubble (w, h, radius, pointsLeft) {
|
|
||||||
const e1rx = 2.25;
|
|
||||||
const e1ry = 2.25;
|
|
||||||
const e2rx = 1.5;
|
|
||||||
const e2ry = 1.5;
|
|
||||||
const e1x = 16 + 7 + e1rx;
|
|
||||||
const e1y = 5 + h + e1ry;
|
|
||||||
const e2x = 16 + e2rx;
|
|
||||||
const e2y = 8 + h + e2ry;
|
|
||||||
const insetR = 4;
|
|
||||||
const pInset1 = 12 + radius;
|
|
||||||
const pInset2 = pInset1 + (2 * insetR);
|
|
||||||
|
|
||||||
let pathString = `
|
|
||||||
M 0 ${radius}
|
|
||||||
A ${radius} ${radius} 0 0 1 ${radius} 0
|
|
||||||
L ${w - radius} 0
|
|
||||||
A ${radius} ${radius} 0 0 1 ${w} ${radius}
|
|
||||||
L ${w} ${h - radius}
|
|
||||||
A ${radius} ${radius} 0 0 1 ${w - radius} ${h}`;
|
|
||||||
|
|
||||||
if (pointsLeft) {
|
|
||||||
pathString += `
|
|
||||||
L ${pInset2} ${h}
|
|
||||||
A ${insetR} ${insetR} 0 0 1 ${pInset2 - insetR} ${h + insetR}
|
|
||||||
A ${insetR} ${insetR} 0 0 1 ${pInset1} ${h}`;
|
|
||||||
} else {
|
|
||||||
pathString += `
|
|
||||||
L ${w - pInset1} ${h}
|
|
||||||
A ${insetR} ${insetR} 0 0 1 ${w - pInset1 - insetR} ${h + insetR}
|
|
||||||
A ${insetR} ${insetR} 0 0 1 ${w - pInset2} ${h}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
pathString += `
|
|
||||||
L ${radius} ${h}
|
|
||||||
A ${radius} ${radius} 0 0 1 0 ${h - radius}
|
|
||||||
Z`;
|
|
||||||
|
|
||||||
const ellipseSvg = (cx, cy, rx, ry) => `
|
|
||||||
<g>
|
|
||||||
<ellipse
|
|
||||||
cx="${cx}" cy="${cy}"
|
|
||||||
rx="${rx}" ry="${ry}"
|
|
||||||
fill="rgba(0, 0, 0, 0.15)"
|
|
||||||
stroke="rgba(0, 0, 0, 0.15)"
|
|
||||||
stroke-width="${STROKE_WIDTH}"
|
|
||||||
/>
|
|
||||||
<ellipse
|
|
||||||
cx="${cx}" cy="${cy}"
|
|
||||||
rx="${rx}" ry="${ry}"
|
|
||||||
fill="white"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
</g>`;
|
|
||||||
let ellipses = [];
|
|
||||||
if (pointsLeft) {
|
|
||||||
ellipses = [
|
|
||||||
ellipseSvg(e1x, e1y, e1rx, e1ry),
|
|
||||||
ellipseSvg(e2x, e2y, e2rx, e2ry)
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
ellipses = [
|
|
||||||
ellipseSvg(w - e1x, e1y, e1rx, e1ry),
|
|
||||||
ellipseSvg(w - e2x, e2y, e2rx, e2ry)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<g>
|
|
||||||
<path d="${pathString}" stroke="rgba(0, 0, 0, 0.15)" stroke-width="${STROKE_WIDTH}"
|
|
||||||
fill="rgba(0, 0, 0, 0.15)" />
|
|
||||||
<path d="${pathString}" stroke="none" fill="white" />
|
|
||||||
${ellipses.join('\n')}
|
|
||||||
</g>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_getTextSize (textFragment) {
|
|
||||||
const svgString = this._wrapSvgFragment(textFragment);
|
|
||||||
if (!this._textSizeCache[svgString]) {
|
|
||||||
this._textSizeCache[svgString] = this.svgRenderer.measure(svgString);
|
|
||||||
if (this._textSizeCache[svgString].height === 0) {
|
|
||||||
// The speech bubble is empty, so use the height of a single line with content (or else it renders
|
|
||||||
// weirdly, see issue #302).
|
|
||||||
const dummyFragment = this._buildTextFragment('X');
|
|
||||||
this._textSizeCache[svgString] = this._getTextSize(dummyFragment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this._textSizeCache[svgString];
|
|
||||||
}
|
|
||||||
|
|
||||||
_wrapSvgFragment (fragment, width, height) {
|
|
||||||
let svgString = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1"`;
|
|
||||||
if (width && height) {
|
|
||||||
const fullWidth = width + STROKE_WIDTH;
|
|
||||||
const fullHeight = height + STROKE_WIDTH + 12;
|
|
||||||
svgString = `${svgString} viewBox="
|
|
||||||
${-STROKE_WIDTH / 2} ${-STROKE_WIDTH / 2} ${fullWidth} ${fullHeight}"
|
|
||||||
width="${fullWidth}" height="${fullHeight}">`;
|
|
||||||
} else {
|
|
||||||
svgString = `${svgString}>`;
|
|
||||||
}
|
|
||||||
svgString = `${svgString} ${fragment} </svg>`;
|
|
||||||
return svgString;
|
|
||||||
}
|
|
||||||
|
|
||||||
_buildTextFragment (text) {
|
|
||||||
const textNode = this.svgTextWrapper.wrapText(MAX_LINE_LENGTH, text);
|
|
||||||
const serializer = new XMLSerializer();
|
|
||||||
return serializer.serializeToString(textNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildString (type, text, pointsLeft) {
|
|
||||||
this.type = type;
|
|
||||||
this.pointsLeft = pointsLeft;
|
|
||||||
this._textFragment = this._buildTextFragment(text);
|
|
||||||
|
|
||||||
let fragment = '';
|
|
||||||
|
|
||||||
const radius = 16;
|
|
||||||
const {x, y, width, height} = this._getTextSize(this._textFragment);
|
|
||||||
const padding = 10;
|
|
||||||
const fullWidth = Math.max(MIN_WIDTH, width) + (2 * padding);
|
|
||||||
const fullHeight = height + (2 * padding);
|
|
||||||
if (this.type === 'say') {
|
|
||||||
fragment += this._speechBubble(fullWidth, fullHeight, radius, this.pointsLeft);
|
|
||||||
} else {
|
|
||||||
fragment += this._thinkBubble(fullWidth, fullHeight, radius, this.pointsLeft);
|
|
||||||
}
|
|
||||||
fragment += `<g transform="translate(${padding - x}, ${padding - y})">${this._textFragment}</g>`;
|
|
||||||
return this._wrapSvgFragment(fragment, fullWidth, fullHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = SVGTextBubble;
|
|
|
@ -1,127 +0,0 @@
|
||||||
const TextWrapper = require('./text-wrapper');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measure text by using a hidden SVG attached to the DOM.
|
|
||||||
* For use with TextWrapper.
|
|
||||||
*/
|
|
||||||
class SVGMeasurementProvider {
|
|
||||||
/**
|
|
||||||
* @param {function} makeTextElement - provides a text node of an SVGElement
|
|
||||||
* with the style of the text to be wrapped.
|
|
||||||
*/
|
|
||||||
constructor (makeTextElement) {
|
|
||||||
this._svgRoot = null;
|
|
||||||
this._cache = {};
|
|
||||||
this.makeTextElement = makeTextElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detach the hidden SVG element from the DOM and forget all references to it and its children.
|
|
||||||
*/
|
|
||||||
dispose () {
|
|
||||||
if (this._svgRoot) {
|
|
||||||
this._svgRoot.parentElement.removeChild(this._svgRoot);
|
|
||||||
this._svgRoot = null;
|
|
||||||
this._svgText = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the TextWrapper before a batch of zero or more calls to measureText().
|
|
||||||
*/
|
|
||||||
beginMeasurementSession () {
|
|
||||||
if (!this._svgRoot) {
|
|
||||||
this._init();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the TextWrapper after a batch of zero or more calls to measureText().
|
|
||||||
*/
|
|
||||||
endMeasurementSession () {
|
|
||||||
this._svgText.textContent = '';
|
|
||||||
this.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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._svgText.textContent = text;
|
|
||||||
this._cache[text] = this._svgText.getComputedTextLength();
|
|
||||||
}
|
|
||||||
return this._cache[text];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a simple SVG containing a text node, hide it, and attach it to the DOM. The text node will be used to
|
|
||||||
* collect text measurements. The SVG must be attached to the DOM: otherwise measurements will generally be zero.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_init () {
|
|
||||||
const svgNamespace = 'http://www.w3.org/2000/svg';
|
|
||||||
|
|
||||||
const svgRoot = document.createElementNS(svgNamespace, 'svg');
|
|
||||||
const svgGroup = document.createElementNS(svgNamespace, 'g');
|
|
||||||
const svgText = this.makeTextElement();
|
|
||||||
|
|
||||||
// hide from the user, including screen readers
|
|
||||||
svgRoot.setAttribute('style', 'position:absolute;visibility:hidden');
|
|
||||||
|
|
||||||
document.body.appendChild(svgRoot);
|
|
||||||
svgRoot.appendChild(svgGroup);
|
|
||||||
svgGroup.appendChild(svgText);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The root SVG element.
|
|
||||||
* @type {SVGSVGElement}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
this._svgRoot = svgRoot;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The leaf SVG element used for text measurement.
|
|
||||||
* @type {SVGTextElement}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
this._svgText = svgText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TextWrapper specialized for SVG text.
|
|
||||||
*/
|
|
||||||
class SVGTextWrapper extends TextWrapper {
|
|
||||||
/**
|
|
||||||
* @param {function} makeTextElement - provides a text node of an SVGElement
|
|
||||||
* with the style of the text to be wrapped.
|
|
||||||
*/
|
|
||||||
constructor (makeTextElement) {
|
|
||||||
super(new SVGMeasurementProvider(makeTextElement));
|
|
||||||
this.makeTextElement = makeTextElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap the provided text into lines restricted to a maximum width. See Unicode Standard Annex (UAX) #14.
|
|
||||||
* @param {number} maxWidth - the maximum allowed width of a line.
|
|
||||||
* @param {string} text - the text to be wrapped. Will be split on whitespace.
|
|
||||||
* @returns {SVGElement} wrapped text node
|
|
||||||
*/
|
|
||||||
wrapText (maxWidth, text) {
|
|
||||||
const lines = super.wrapText(maxWidth, text);
|
|
||||||
const textElement = this.makeTextElement();
|
|
||||||
for (const line of lines) {
|
|
||||||
const tspanNode = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
|
||||||
tspanNode.setAttribute('x', '0');
|
|
||||||
tspanNode.setAttribute('dy', '1.2em');
|
|
||||||
tspanNode.textContent = line;
|
|
||||||
textElement.appendChild(tspanNode);
|
|
||||||
}
|
|
||||||
return textElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = SVGTextWrapper;
|
|
|
@ -16,7 +16,7 @@ const GraphemeBreaker = require('!ify-loader!grapheme-breaker');
|
||||||
* break opportunities.
|
* break opportunities.
|
||||||
* Reference material:
|
* Reference material:
|
||||||
* - Unicode Standard Annex #14: http://unicode.org/reports/tr14/
|
* - Unicode Standard Annex #14: http://unicode.org/reports/tr14/
|
||||||
* - Unicode Standard Annex #39: http://unicode.org/reports/tr29/
|
* - Unicode Standard Annex #29: http://unicode.org/reports/tr29/
|
||||||
* - "JavaScript has a Unicode problem" by Mathias Bynens: https://mathiasbynens.be/notes/javascript-unicode
|
* - "JavaScript has a Unicode problem" by Mathias Bynens: https://mathiasbynens.be/notes/javascript-unicode
|
||||||
*/
|
*/
|
||||||
class TextWrapper {
|
class TextWrapper {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue