Compare commits
68 Commits
greenkeepe
...
greenkeepe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf8025d29c | ||
|
|
27c70a7542 | ||
|
|
a79df7af59 | ||
|
|
ed6c707cba | ||
|
|
6bdaebcb3b | ||
|
|
8b54df18af | ||
|
|
e98fb37434 | ||
|
|
c3d07db39a | ||
|
|
5ef4ae63ba | ||
|
|
ab14e224d6 | ||
|
|
1a5bd39f77 | ||
|
|
e478ad4590 | ||
|
|
f5ddc24c7e | ||
|
|
c313f6ac89 | ||
|
|
9fc82a8fc3 | ||
|
|
e8d30d7629 | ||
|
|
fab2a52a96 | ||
|
|
7bb8b3829e | ||
|
|
b60b2aadde | ||
|
|
e8fb7daaec | ||
|
|
57e40e20ab | ||
|
|
1021877ba6 | ||
|
|
4a55d63ada | ||
|
|
c9c780aa69 | ||
|
|
b92354b1bf | ||
|
|
6c8b5bc2a9 | ||
|
|
590c2ca084 | ||
|
|
757d7e3c96 | ||
|
|
e365a909dc | ||
|
|
bffe80086e | ||
|
|
95a3c0dc6f | ||
|
|
05928eb400 | ||
|
|
008dc5b15b | ||
|
|
924050baaf | ||
|
|
0b9ee47fa1 | ||
|
|
9177705e04 | ||
|
|
3e710e66ec | ||
|
|
2f14126d0b | ||
|
|
4e9223adc6 | ||
|
|
5419d3d2c3 | ||
|
|
d4df59b23b | ||
|
|
b304ea8fdf | ||
|
|
f9428ee096 | ||
|
|
9526612d79 | ||
|
|
fb767b7553 | ||
|
|
e864018d87 | ||
|
|
e0b420a183 | ||
|
|
a24b853af6 | ||
|
|
73896b6f32 | ||
|
|
80630a64da | ||
|
|
e31934f6a9 | ||
|
|
8f007c0986 | ||
|
|
3c79a5562e | ||
|
|
d59d45b6c8 | ||
|
|
19ee8e8eaa | ||
|
|
fe01fea9d0 | ||
|
|
5fb9346036 | ||
|
|
3d373571f8 | ||
|
|
152cf028cc | ||
|
|
147b79d319 | ||
|
|
f2a7085492 | ||
|
|
996a1d6cf7 | ||
|
|
61bf4c84c3 | ||
|
|
7628c1e7f9 | ||
|
|
9f7bd971c9 | ||
|
|
44d2fdeba8 | ||
|
|
e022222365 | ||
|
|
be5ab2e689 |
@@ -49,11 +49,11 @@
|
||||
"grapheme-breaker": "0.3.2",
|
||||
"hull.js": "0.2.10",
|
||||
"ify-loader": "1.0.4",
|
||||
"linebreak": "0.3.0",
|
||||
"linebreak": "1.0.2",
|
||||
"minilog": "3.1.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"scratch-storage": "^1.0.0",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20190125192231",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20190523193400",
|
||||
"twgl.js": "4.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,20 +79,31 @@ class BitmapSkin extends Skin {
|
||||
setBitmap (bitmapData, costumeResolution, rotationCenter) {
|
||||
const gl = this._renderer.gl;
|
||||
|
||||
// Preferably bitmapData is ImageData. ImageData speeds up updating
|
||||
// Silhouette and is better handled by more browsers in regards to
|
||||
// memory.
|
||||
let textureData = bitmapData;
|
||||
if (bitmapData instanceof HTMLCanvasElement) {
|
||||
// Given a HTMLCanvasElement get the image data to pass to webgl and
|
||||
// Silhouette.
|
||||
const context = bitmapData.getContext('2d');
|
||||
textureData = context.getImageData(0, 0, bitmapData.width, bitmapData.height);
|
||||
}
|
||||
|
||||
if (this._texture) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this._texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmapData);
|
||||
this._silhouette.update(bitmapData);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
|
||||
this._silhouette.update(textureData);
|
||||
} else {
|
||||
// TODO: mipmaps?
|
||||
const textureOptions = {
|
||||
auto: true,
|
||||
wrap: gl.CLAMP_TO_EDGE,
|
||||
src: bitmapData
|
||||
src: textureData
|
||||
};
|
||||
|
||||
this._texture = twgl.createTexture(gl, textureOptions);
|
||||
this._silhouette.update(bitmapData);
|
||||
this._silhouette.update(textureData);
|
||||
}
|
||||
|
||||
// Do these last in case any of the above throws an exception
|
||||
|
||||
@@ -36,8 +36,10 @@ const getLocalPosition = (drawable, vec) => {
|
||||
// localPosition matches that transformation.
|
||||
localPosition[0] = 0.5 - (((v0 * m[0]) + (v1 * m[4]) + m[12]) / d);
|
||||
localPosition[1] = (((v0 * m[1]) + (v1 * m[5]) + m[13]) / d) + 0.5;
|
||||
// Apply texture effect transform.
|
||||
EffectTransform.transformPoint(drawable, localPosition, localPosition);
|
||||
// Apply texture effect transform if the localPosition is within the drawable's space.
|
||||
if ((localPosition[0] >= 0 && localPosition[0] < 1) && (localPosition[1] >= 0 && localPosition[1] < 1)) {
|
||||
EffectTransform.transformPoint(drawable, localPosition, localPosition);
|
||||
}
|
||||
return localPosition;
|
||||
};
|
||||
|
||||
|
||||
@@ -154,6 +154,13 @@ class PenSkin extends Skin {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if alpha is premultiplied, false otherwise
|
||||
*/
|
||||
get hasPremultipliedAlpha () {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<number>} the "native" size, in texels, of this skin. [width, height]
|
||||
*/
|
||||
@@ -181,8 +188,9 @@ class PenSkin extends Skin {
|
||||
clear () {
|
||||
const gl = this._renderer.gl;
|
||||
twgl.bindFramebufferInfo(gl, this._framebuffer);
|
||||
|
||||
gl.clearColor(1, 1, 1, 0);
|
||||
|
||||
/* Reset framebuffer to transparent black */
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const ctx = this._canvas.getContext('2d');
|
||||
@@ -598,7 +606,7 @@ class PenSkin extends Skin {
|
||||
this._silhouetteBuffer = twgl.createFramebufferInfo(gl, [{format: gl.RGBA}], width, height);
|
||||
}
|
||||
|
||||
gl.clearColor(1, 1, 1, 0);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
this._silhouetteDirty = true;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1620,7 +1627,14 @@ class RenderWebGL extends EventEmitter {
|
||||
}
|
||||
|
||||
twgl.setUniforms(currentShader, uniforms);
|
||||
|
||||
|
||||
/* adjust blend function for this skin */
|
||||
if (drawable.skin.hasPremultipliedAlpha){
|
||||
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
} else {
|
||||
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
twgl.drawBufferInfo(gl, this._bufferInfo, gl.TRIANGLES);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,10 +77,14 @@ class SVGSkin extends Skin {
|
||||
this._textureScale = newScale;
|
||||
this._svgRenderer._draw(this._textureScale, () => {
|
||||
if (this._textureScale === newScale) {
|
||||
const canvas = this._svgRenderer.canvas;
|
||||
const context = canvas.getContext('2d');
|
||||
const textureData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const gl = this._renderer.gl;
|
||||
gl.bindTexture(gl.TEXTURE_2D, this._texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._svgRenderer.canvas);
|
||||
this._silhouette.update(this._svgRenderer.canvas);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
|
||||
this._silhouette.update(textureData);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -99,20 +103,28 @@ class SVGSkin extends Skin {
|
||||
this._svgRenderer.fromString(svgData, 1, () => {
|
||||
const gl = this._renderer.gl;
|
||||
this._textureScale = this._maxTextureScale = 1;
|
||||
|
||||
// Pull out the ImageData from the canvas. ImageData speeds up
|
||||
// updating Silhouette and is better handled by more browsers in
|
||||
// regards to memory.
|
||||
const canvas = this._svgRenderer.canvas;
|
||||
const context = canvas.getContext('2d');
|
||||
const textureData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (this._texture) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this._texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._svgRenderer.canvas);
|
||||
this._silhouette.update(this._svgRenderer.canvas);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData);
|
||||
this._silhouette.update(textureData);
|
||||
} else {
|
||||
// TODO: mipmaps?
|
||||
const textureOptions = {
|
||||
auto: true,
|
||||
wrap: gl.CLAMP_TO_EDGE,
|
||||
src: this._svgRenderer.canvas
|
||||
src: textureData
|
||||
};
|
||||
|
||||
this._texture = twgl.createTexture(gl, textureOptions);
|
||||
this._silhouette.update(this._svgRenderer.canvas);
|
||||
this._silhouette.update(textureData);
|
||||
}
|
||||
|
||||
const maxDimension = Math.max(this._svgRenderer.canvas.width, this._svgRenderer.canvas.height);
|
||||
|
||||
@@ -19,12 +19,12 @@ let __SilhouetteUpdateCanvas;
|
||||
* @param {number} y - y
|
||||
* @return {number} Alpha value for x/y position
|
||||
*/
|
||||
const getPoint = ({_width: width, _height: height, _data: data}, x, y) => {
|
||||
const getPoint = ({_width: width, _height: height, _colorData: data}, x, y) => {
|
||||
// 0 if outside bouds, otherwise read from data.
|
||||
if (x >= width || y >= height || x < 0 || y < 0) {
|
||||
return 0;
|
||||
}
|
||||
return data[(y * width) + x];
|
||||
return data[(((y * width) + x) * 4) + 3];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,7 +76,6 @@ class Silhouette {
|
||||
* The data representing a skin's silhouette shape.
|
||||
* @type {Uint8ClampedArray}
|
||||
*/
|
||||
this._data = null;
|
||||
this._colorData = null;
|
||||
|
||||
this.colorAtNearest = this.colorAtLinear = (_, dst) => dst.fill(0);
|
||||
@@ -88,28 +87,33 @@ class Silhouette {
|
||||
* rendering can be queried from.
|
||||
*/
|
||||
update (bitmapData) {
|
||||
const canvas = Silhouette._updateCanvas();
|
||||
const width = this._width = canvas.width = bitmapData.width;
|
||||
const height = this._height = canvas.height = bitmapData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
let imageData;
|
||||
if (bitmapData instanceof ImageData) {
|
||||
// If handed ImageData directly, use it directly.
|
||||
imageData = bitmapData;
|
||||
this._width = bitmapData.width;
|
||||
this._height = bitmapData.height;
|
||||
} else {
|
||||
// Draw about anything else to our update canvas and poll image data
|
||||
// from that.
|
||||
const canvas = Silhouette._updateCanvas();
|
||||
const width = this._width = canvas.width = bitmapData.width;
|
||||
const height = this._height = canvas.height = bitmapData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!(width && height)) {
|
||||
return;
|
||||
if (!(width && height)) {
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(bitmapData, 0, 0, width, height);
|
||||
imageData = ctx.getImageData(0, 0, width, height);
|
||||
}
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(bitmapData, 0, 0, width, height);
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
this._data = new Uint8ClampedArray(imageData.data.length / 4);
|
||||
this._colorData = imageData.data;
|
||||
// delete our custom overriden "uninitalized" color functions
|
||||
// let the prototype work for itself
|
||||
delete this.colorAtNearest;
|
||||
delete this.colorAtLinear;
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
this._data[i / 4] = imageData.data[i + 3];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +170,7 @@ class Silhouette {
|
||||
* @return {boolean} If the nearest pixel has an alpha value.
|
||||
*/
|
||||
isTouchingNearest (vec) {
|
||||
if (!this._data) return;
|
||||
if (!this._colorData) return;
|
||||
return getPoint(
|
||||
this,
|
||||
Math.floor(vec[0] * (this._width - 1)),
|
||||
@@ -181,7 +185,7 @@ class Silhouette {
|
||||
* @return {boolean} Any of the pixels have some alpha.
|
||||
*/
|
||||
isTouchingLinear (vec) {
|
||||
if (!this._data) return;
|
||||
if (!this._colorData) return;
|
||||
const x = Math.floor(vec[0] * (this._width - 1));
|
||||
const y = Math.floor(vec[1] * (this._height - 1));
|
||||
return getPoint(this, x, y) > 0 ||
|
||||
|
||||
@@ -76,6 +76,13 @@ class Skin extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if alpha is premultiplied, false otherwise
|
||||
*/
|
||||
get hasPremultipliedAlpha () {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {int} the unique ID for this Skin.
|
||||
*/
|
||||
|
||||
287
src/TextBubbleSkin.js
Normal file
287
src/TextBubbleSkin.js
Normal file
@@ -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;
|
||||
@@ -3,13 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scratch WebGL rendering demo</title>
|
||||
<style>
|
||||
#scratch-stage { width: 480px; }
|
||||
</style>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
</head>
|
||||
<body style="background: lightsteelblue">
|
||||
<canvas id="scratch-stage" width="10" height="10" style="border:3px dashed black"></canvas>
|
||||
<canvas id="debug-canvas" width="10" height="10" style="border:3px dashed red"></canvas>
|
||||
<body>
|
||||
<canvas id="scratch-stage" width="10" height="10"></canvas>
|
||||
<canvas id="debug-canvas" width="10" height="10"></canvas>
|
||||
<p>
|
||||
<label for="fudgeproperty">Property to tweak:</label>
|
||||
<select id="fudgeproperty">
|
||||
@@ -18,6 +16,7 @@
|
||||
<option value="direction">Direction</option>
|
||||
<option value="scalex">Scale X</option>
|
||||
<option value="scaley">Scale Y</option>
|
||||
<option value="scaleboth">Scale (both dimensions)</option>
|
||||
<option value="color">Color</option>
|
||||
<option value="fisheye">Fisheye</option>
|
||||
<option value="whirl">Whirl</option>
|
||||
@@ -30,8 +29,12 @@
|
||||
<input type="range" id="fudge" style="width:50%" value="90" min="-90" max="270" step="any">
|
||||
</p>
|
||||
<p>
|
||||
<label for="fudgeMin">Min:</label><input id="fudgeMin" type="number">
|
||||
<label for="fudgeMax">Max:</label><input id="fudgeMax" type="number">
|
||||
<label for="stage-scale">Stage scale:</label>
|
||||
<input type="range" style="width:50%" id="stage-scale" value="1" min="1" max="2.5" step="any">
|
||||
</p>
|
||||
<p>
|
||||
<label for="fudgeMin">Min:</label><input id="fudgeMin" type="number" value="0">
|
||||
<label for="fudgeMax">Max:</label><input id="fudgeMax" type="number" value="200">
|
||||
</p>
|
||||
<script src="playground.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
const ScratchRender = require('../RenderWebGL');
|
||||
const getMousePosition = require('./getMousePosition');
|
||||
|
||||
var canvas = document.getElementById('scratch-stage');
|
||||
var fudge = 90;
|
||||
var renderer = new ScratchRender(canvas);
|
||||
const canvas = document.getElementById('scratch-stage');
|
||||
let fudge = 90;
|
||||
const renderer = new ScratchRender(canvas);
|
||||
renderer.setLayerGroupOrdering(['group1']);
|
||||
|
||||
var drawableID = renderer.createDrawable('group1');
|
||||
const drawableID = renderer.createDrawable('group1');
|
||||
renderer.updateDrawableProperties(drawableID, {
|
||||
position: [0, 0],
|
||||
scale: [100, 100],
|
||||
direction: 90
|
||||
});
|
||||
|
||||
var drawableID2 = renderer.createDrawable('group1');
|
||||
var wantBitmapSkin = false;
|
||||
const WantedSkinType = {
|
||||
bitmap: 'bitmap',
|
||||
vector: 'vector',
|
||||
pen: 'pen'
|
||||
};
|
||||
|
||||
const drawableID2 = renderer.createDrawable('group1');
|
||||
const wantedSkin = WantedSkinType.vector;
|
||||
|
||||
// Bitmap (squirrel)
|
||||
var image = new Image();
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => {
|
||||
var bitmapSkinId = renderer.createBitmapSkin(image);
|
||||
if (wantBitmapSkin) {
|
||||
const bitmapSkinId = renderer.createBitmapSkin(image);
|
||||
if (wantedSkin === WantedSkinType.bitmap) {
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: bitmapSkinId
|
||||
});
|
||||
@@ -30,44 +36,83 @@ image.crossOrigin = 'anonymous';
|
||||
image.src = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/';
|
||||
|
||||
// SVG (cat 1-a)
|
||||
var xhr = new XMLHttpRequest();
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('load', function () {
|
||||
var skinId = renderer.createSVGSkin(xhr.responseText);
|
||||
if (!wantBitmapSkin) {
|
||||
const skinId = renderer.createSVGSkin(xhr.responseText);
|
||||
if (wantedSkin === WantedSkinType.vector) {
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: skinId
|
||||
});
|
||||
}
|
||||
});
|
||||
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/b7853f557e4426412e64bb3da6531a99.svg/get/');
|
||||
xhr.send();
|
||||
|
||||
var posX = 0;
|
||||
var posY = 0;
|
||||
var scaleX = 100;
|
||||
var scaleY = 100;
|
||||
var fudgeProperty = 'posx';
|
||||
if (wantedSkin === WantedSkinType.pen) {
|
||||
const penSkinID = renderer.createPenSkin();
|
||||
|
||||
const fudgePropertyInput = document.getElementById('fudgeproperty');
|
||||
fudgePropertyInput.addEventListener('change', event => {
|
||||
fudgeProperty = event.target.value;
|
||||
});
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: penSkinID
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', event => {
|
||||
let rect = canvas.getBoundingClientRect();
|
||||
|
||||
let x = event.clientX - rect.left;
|
||||
let y = event.clientY - rect.top;
|
||||
|
||||
renderer.penLine(penSkinID, {
|
||||
color4f: [Math.random(), Math.random(), Math.random(), 1],
|
||||
diameter: 8
|
||||
},
|
||||
x - 240, 180 - y, (Math.random() * 480) - 240, (Math.random() * 360) - 180);
|
||||
});
|
||||
}
|
||||
|
||||
let posX = 0;
|
||||
let posY = 0;
|
||||
let scaleX = 100;
|
||||
let scaleY = 100;
|
||||
let fudgeProperty = 'posx';
|
||||
|
||||
const fudgeInput = document.getElementById('fudge');
|
||||
|
||||
const fudgePropertyInput = document.getElementById('fudgeproperty');
|
||||
const fudgeMinInput = document.getElementById('fudgeMin');
|
||||
fudgeMinInput.addEventListener('change', event => {
|
||||
fudgeInput.min = event.target.valueAsNumber;
|
||||
});
|
||||
|
||||
const fudgeMaxInput = document.getElementById('fudgeMax');
|
||||
fudgeMaxInput.addEventListener('change', event => {
|
||||
|
||||
/* eslint require-jsdoc: 0 */
|
||||
const updateFudgeProperty = event => {
|
||||
fudgeProperty = event.target.value;
|
||||
};
|
||||
|
||||
const updateFudgeMin = event => {
|
||||
fudgeInput.min = event.target.valueAsNumber;
|
||||
};
|
||||
|
||||
const updateFudgeMax = event => {
|
||||
fudgeInput.max = event.target.valueAsNumber;
|
||||
});
|
||||
};
|
||||
|
||||
fudgePropertyInput.addEventListener('change', updateFudgeProperty);
|
||||
fudgePropertyInput.addEventListener('init', updateFudgeProperty);
|
||||
|
||||
fudgeMinInput.addEventListener('change', updateFudgeMin);
|
||||
fudgeMinInput.addEventListener('init', updateFudgeMin);
|
||||
|
||||
fudgeMaxInput.addEventListener('change', updateFudgeMax);
|
||||
fudgeMaxInput.addEventListener('init', updateFudgeMax);
|
||||
|
||||
// Ugly hack to properly set the values of the inputs on page load,
|
||||
// since they persist across reloads, at least in Firefox.
|
||||
// The best ugly hacks are the ones that reduce code duplication!
|
||||
fudgePropertyInput.dispatchEvent(new CustomEvent('init'));
|
||||
fudgeMinInput.dispatchEvent(new CustomEvent('init'));
|
||||
fudgeMaxInput.dispatchEvent(new CustomEvent('init'));
|
||||
fudgeInput.dispatchEvent(new CustomEvent('init'));
|
||||
|
||||
const handleFudgeChanged = function (event) {
|
||||
fudge = event.target.valueAsNumber;
|
||||
var props = {};
|
||||
const props = {};
|
||||
switch (fudgeProperty) {
|
||||
case 'posx':
|
||||
props.position = [fudge, posY];
|
||||
@@ -88,6 +133,11 @@ const handleFudgeChanged = function (event) {
|
||||
props.scale = [scaleX, fudge];
|
||||
scaleY = fudge;
|
||||
break;
|
||||
case 'scaleboth':
|
||||
props.scale = [fudge, fudge];
|
||||
scaleX = fudge;
|
||||
scaleY = fudge;
|
||||
break;
|
||||
case 'color':
|
||||
props.color = fudge;
|
||||
break;
|
||||
@@ -112,17 +162,28 @@ const handleFudgeChanged = function (event) {
|
||||
}
|
||||
renderer.updateDrawableProperties(drawableID2, props);
|
||||
};
|
||||
|
||||
fudgeInput.addEventListener('input', handleFudgeChanged);
|
||||
fudgeInput.addEventListener('change', handleFudgeChanged);
|
||||
fudgeInput.addEventListener('init', handleFudgeChanged);
|
||||
|
||||
const updateStageScale = event => {
|
||||
renderer.resize(480 * event.target.valueAsNumber, 360 * event.target.valueAsNumber);
|
||||
};
|
||||
|
||||
const stageScaleInput = document.getElementById('stage-scale');
|
||||
|
||||
stageScaleInput.addEventListener('input', updateStageScale);
|
||||
stageScaleInput.addEventListener('change', updateStageScale);
|
||||
|
||||
canvas.addEventListener('mousemove', event => {
|
||||
var mousePos = getMousePosition(event, canvas);
|
||||
const mousePos = getMousePosition(event, canvas);
|
||||
renderer.extractColor(mousePos.x, mousePos.y, 30);
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', event => {
|
||||
var mousePos = getMousePosition(event, canvas);
|
||||
var pickID = renderer.pick(mousePos.x, mousePos.y);
|
||||
const mousePos = getMousePosition(event, canvas);
|
||||
const pickID = renderer.pick(mousePos.x, mousePos.y);
|
||||
console.log('You clicked on ' + (pickID < 0 ? 'nothing' : 'ID# ' + pickID));
|
||||
if (pickID >= 0) {
|
||||
console.dir(renderer.extractDrawable(pickID, mousePos.x, mousePos.y));
|
||||
@@ -137,5 +198,5 @@ const drawStep = function () {
|
||||
};
|
||||
drawStep();
|
||||
|
||||
var debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas');
|
||||
const debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas');
|
||||
renderer.setDebugCanvas(debugCanvas);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scratch WebGL Query Playground</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
<style>
|
||||
input[type=range][orient=vertical] {
|
||||
writing-mode: bt-lr; /* IE */
|
||||
@@ -11,8 +12,6 @@
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
canvas {
|
||||
border: 3px dashed black;
|
||||
|
||||
/* https://stackoverflow.com/a/7665647 */
|
||||
image-rendering: optimizeSpeed; /* Older versions of FF */
|
||||
image-rendering: -moz-crisp-edges; /* FF 6.0+ */
|
||||
@@ -23,7 +22,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background: lightsteelblue">
|
||||
<body>
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend>Query Canvases</legend>
|
||||
@@ -63,7 +62,7 @@
|
||||
<input id="cursorY" type="range" orient="vertical" step="0.25" value="0" />
|
||||
</td>
|
||||
<td>
|
||||
<canvas id="renderCanvas" width="480" height="360" style="border:3px dashed black"></canvas>
|
||||
<canvas id="renderCanvas" width="480" height="360"></canvas>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
11
src/playground/style.css
Normal file
11
src/playground/style.css
Normal file
@@ -0,0 +1,11 @@
|
||||
body {
|
||||
background: lightsteelblue;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px dashed black;
|
||||
}
|
||||
|
||||
#debug-canvas {
|
||||
border-color: red;
|
||||
}
|
||||
@@ -194,12 +194,6 @@ void main()
|
||||
discard;
|
||||
}
|
||||
#endif // DRAW_MODE_colorMask
|
||||
|
||||
// WebGL defaults to premultiplied alpha
|
||||
#ifndef DRAW_MODE_stamp
|
||||
gl_FragColor.rgb *= gl_FragColor.a;
|
||||
#endif // DRAW_MODE_stamp
|
||||
|
||||
#endif // DRAW_MODE_silhouette
|
||||
|
||||
#else // DRAW_MODE_lineSample
|
||||
|
||||
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.
|
||||
* Reference material:
|
||||
* - 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
|
||||
*/
|
||||
class TextWrapper {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global vm, render, Promise */
|
||||
/* global vm, Promise */
|
||||
const {Chromeless} = require('chromeless');
|
||||
const test = require('tap').test;
|
||||
const path = require('path');
|
||||
@@ -101,16 +101,6 @@ const testFile = file => test(file, async t => {
|
||||
}
|
||||
});
|
||||
|
||||
const testBubbles = () => test('bubble snapshot', async t => {
|
||||
const bubbleSvg = await chromeless.goto(`file://${indexHTML}`)
|
||||
.evaluate(() => {
|
||||
const testString = '<e*&%$&^$></!abc\'>';
|
||||
return render._svgTextBubble._buildTextFragment(testString);
|
||||
});
|
||||
t.matchSnapshot(bubbleSvg, 'bubble-text-snapshot');
|
||||
t.end();
|
||||
});
|
||||
|
||||
// immediately invoked async function to let us wait for each test to finish before starting the next.
|
||||
(async () => {
|
||||
const files = fs.readdirSync(testDir())
|
||||
@@ -120,8 +110,6 @@ const testBubbles = () => test('bubble snapshot', async t => {
|
||||
await testFile(file);
|
||||
}
|
||||
|
||||
await testBubbles();
|
||||
|
||||
// close the browser window we used
|
||||
await chromeless.end();
|
||||
})();
|
||||
|
||||
@@ -51,7 +51,7 @@ module.exports = [
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
context: 'src/playground',
|
||||
from: '*.html'
|
||||
from: '*.+(html|css)'
|
||||
}
|
||||
])
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user