Compare commits

...

68 Commits

Author SHA1 Message Date
greenkeeper[bot]
bf8025d29c fix(package): update linebreak to version 1.0.2
Closes #471
2019-06-17 02:39:02 +00:00
Karishma Chadha
27c70a7542 Merge pull request #463 from LLK/greenkeeper/scratch-svg-renderer-0.2.0-prerelease.20190523193400
Update scratch-svg-renderer to the latest version 🚀
2019-06-05 11:02:10 -04:00
Benjamin Wheeler
a79df7af59 Merge pull request #466 from LLK/revert-423-more-circular-pen-dots
Revert "Tweak scalingVector to make dots appear to be more circular"
2019-05-30 11:58:22 -04:00
Benjamin Wheeler
ed6c707cba Revert "Tweak scalingVector to make dots appear to be more circular" 2019-05-30 11:50:54 -04:00
Chris Willis-Ford
6bdaebcb3b Merge pull request #426 from adroitwhiz/playground-improvements
Playground improvements
2019-05-28 16:10:36 -07:00
Chris Willis-Ford
8b54df18af Merge pull request #423 from ktbee/more-circular-pen-dots
Tweak scalingVector to make dots appear to be more circular
2019-05-28 15:39:34 -07:00
Paul Kaplan
e98fb37434 Merge pull request #451 from adroitwhiz/canvas-text-bubble
Implement canvas-based TextBubbleSkin
2019-05-24 08:54:11 -04:00
greenkeeper[bot]
c3d07db39a fix(package): update scratch-svg-renderer to version 0.2.0-prerelease.20190523193400 2019-05-23 19:37:48 +00:00
Karishma Chadha
5ef4ae63ba Merge pull request #458 from LLK/greenkeeper/scratch-svg-renderer-0.2.0-prerelease.20190521170426
Update scratch-svg-renderer to the latest version 🚀
2019-05-22 17:29:49 -04:00
adroitwhiz
ab14e224d6 add default fudge min/max values 2019-05-22 05:02:44 -04:00
adroitwhiz
1a5bd39f77 replace janky boolean logic 2019-05-22 05:01:16 -04:00
adroitwhiz
e478ad4590 es6ify playground.js 2019-05-22 04:58:06 -04:00
greenkeeper[bot]
f5ddc24c7e fix(package): update scratch-svg-renderer to version 0.2.0-prerelease.20190521170426 2019-05-21 17:05:46 +00:00
adroitwhiz
c313f6ac89 fix documentation 2019-05-18 00:46:09 -04:00
Katie Broida
9fc82a8fc3 Tweak scalingVector to make dots appear to be more circular instead of oval 2019-05-13 13:27:08 -04:00
adroitwhiz
e8d30d7629 Merge branch 'develop' of https://github.com/LLK/scratch-render into playground-improvements 2019-05-05 16:52:05 -04:00
adroitwhiz
fab2a52a96 build's fixed 🦀 2019-05-05 16:46:13 -04:00
adroitwhiz
7bb8b3829e roundBounds! it does nothing! 2019-05-05 03:06:04 -04:00
adroitwhiz
b60b2aadde wait, is the build fixed? 2019-05-01 02:16:38 -04:00
adroitwhiz
e8fb7daaec remove no-longer-applicable unit test 2019-04-30 23:04:26 -04:00
adroitwhiz
57e40e20ab Revert playground changes 2019-04-30 22:56:17 -04:00
adroitwhiz
1021877ba6 Canvas-based TextBubbleSkin 2019-04-30 22:51:43 -04:00
Chris Willis-Ford
4a55d63ada Merge pull request #424 from ktbee/limit-mosaic-effect
Only check position against effect transform if it falls within the Drawable's space
2019-04-29 17:19:51 -07:00
Chris Willis-Ford
c9c780aa69 Merge pull request #418 from peabrainiac/develop
pen transparency fix
2019-04-29 17:04:15 -07:00
adroitwhiz
b92354b1bf Merge branch 'develop' of https://github.com/LLK/scratch-render into playground-improvements 2019-04-23 13:21:41 -04:00
adroitwhiz
6c8b5bc2a9 Fix webpack glob 2019-04-23 13:21:40 -04:00
Paul Kaplan
590c2ca084 Merge pull request #440 from LLK/revert-419-coordinates-fixups-2
Revert "Adjust CPU `isTouchingColor` to match GPU results (again)"
2019-04-19 16:14:10 -04:00
Paul Kaplan
757d7e3c96 Revert "Adjust CPU isTouchingColor to match GPU results (again)" 2019-04-19 16:13:47 -04:00
Paul Kaplan
e365a909dc Merge pull request #439 from LLK/greenkeeper/scratch-svg-renderer-0.2.0-prerelease.20190419183947
fix(package): update scratch-svg-renderer to version 0.2.0-prerelease…
2019-04-19 16:03:53 -04:00
greenkeeper[bot]
bffe80086e fix(package): update scratch-svg-renderer to version 0.2.0-prerelease.20190419183947
Closes #430
2019-04-19 18:45:16 +00:00
adroitwhiz
95a3c0dc6f Appease ESLint 2019-04-19 13:50:52 -04:00
adroitwhiz
05928eb400 Add very basic pen testing to playground 2019-04-19 13:44:47 -04:00
Chris Willis-Ford
008dc5b15b Merge pull request #419 from cwillisf/coordinates-fixups-2
Adjust CPU `isTouchingColor` to match GPU results (again)
2019-04-10 11:35:25 -07:00
adroitwhiz
924050baaf Merge branch 'develop' of https://github.com/LLK/scratch-render into playground-improvements 2019-03-29 10:19:41 -04:00
adroitwhiz
0b9ee47fa1 Add "Scale (both)" option and fix quotes 2019-03-29 10:19:37 -04:00
Michael "Z" Goddard
9177705e04 Merge pull request #414 from mzgoddard/image-data-texture
ImageData WebGL Textures
2019-03-26 12:12:57 -04:00
adroitwhiz
3e710e66ec Move playground style rules into stylesheet 2019-03-24 03:03:51 -04:00
adroitwhiz
2f14126d0b Add stage scale slider to playground 2019-03-24 02:50:11 -04:00
adroitwhiz
4e9223adc6 More fixes for loading in playground 2019-03-24 02:43:15 -04:00
adroitwhiz
5419d3d2c3 Use updated Scratch cat SVG in playground 2019-03-24 02:42:13 -04:00
adroitwhiz
d4df59b23b Fix playground not re-reading inputs after page reload 2019-03-24 02:38:05 -04:00
Christopher Willis-Ford
b304ea8fdf Make touching-color test more robust against GPU imprecision
Previously, the `color-touching-tests.sb2` test "touches a color that
doesn't actually exist right now" would use a sprite with ghost 50,
blended against another sprite, to create the color that "doesn't
actually exist" when the query sprite is skipped. Unfortunately the
blend result was near a bit-boundary and, depending on the specific
hardware used, that test could fail on the GPU. When the renderer uses
the CPU path this test works fine, though, so the existing problem went
unnoticed.

To fix the problem I changed the project to use ghost 30 instead, which
results in a color that is less near a bit boundary and is therefore
less likely to fail on specific hardware.

As an example of what was happening: the `touching color` block was
checking for `RGB(127,101,216)` with a mask of `RGB(0xF8,0xF8,0xF0)`. On
the CPU it would find `RGB(120,99,215)`, which is in range, but on some
GPUs the closest color it could find was `RGB(119,98,215)` which
mismatches on all four of the least significant bits -- one of which is
enabled in the mask.
2019-03-20 22:58:36 -07:00
Christopher Willis-Ford
f9428ee096 Run test projects in each GPU usage mode 2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
9526612d79 Add touching-color test to verify stencil use 2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
fb767b7553 Fix exception on first button click 2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
e864018d87 Iterate drawables in the same order on CPU & GPU 2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
e0b420a183 Use alpha test to avoid false touching-color 2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
a24b853af6 Fix (x,y) => point[] conversion comments 2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
73896b6f32 Fix direction for Y iteration on CPU path
For some reason the JavaScript engine insists on running the code
instead of doing what the comment says. I guess they should match.
2019-03-20 11:21:05 -07:00
Christopher Willis-Ford
80630a64da Adjust CPU isTouchingColor to match GPU results 2019-03-20 11:21:05 -07:00
Michael "Z" Goddard
e31934f6a9 update Skin textures with ImageData
When possible pass ImageData to texture creation and updating to help
remove chance of references that keep canvas and underlying data from
being garbage collected.
2019-03-19 17:52:21 -04:00
Katie Broida
8f007c0986 Only check local position against mosaic effect transform if it falls within the drawable's space 2019-03-18 15:25:40 -04:00
Paul Kaplan
3c79a5562e Merge pull request #421 from LLK/greenkeeper/scratch-svg-renderer-0.2.0-prerelease.20190304180800
Update scratch-svg-renderer to the latest version 🚀
2019-03-12 08:46:47 -04:00
greenkeeper[bot]
d59d45b6c8 fix(package): update scratch-svg-renderer to version 0.2.0-prerelease.20190304180800 2019-03-04 18:09:20 +00:00
Michael "Z" Goddard
19ee8e8eaa Merge pull request #415 from mzgoddard/drop-silhouette-alpha-buffer
Replace Silhouette._data with Silhouette._colorData
2019-03-04 12:56:58 -05:00
peabrainiac
fe01fea9d0 Update RenderWebGL.js 2019-03-03 18:49:04 +01:00
peabrainiac
5fb9346036 Update RenderWebGL.js 2019-03-03 18:42:10 +01:00
peabrainiac
3d373571f8 Update PenSkin.js 2019-03-03 18:40:09 +01:00
peabrainiac
152cf028cc Update Skin.js 2019-03-03 18:39:40 +01:00
peabrainiac
147b79d319 Update RenderWebGL.js 2019-03-03 18:12:13 +01:00
peabrainiac
f2a7085492 Update RenderWebGL.js 2019-03-03 17:58:12 +01:00
peabrainiac
996a1d6cf7 Update sprite.frag 2019-03-03 17:55:55 +01:00
peabrainiac
61bf4c84c3 Update RenderWebGL.js 2019-03-02 22:35:42 +01:00
peabrainiac
7628c1e7f9 Update RenderWebGL.js
Modified blend function in `_drawThese` to blend skins with premultiplied alpha correctly
2019-03-02 20:59:00 +01:00
peabrainiac
9f7bd971c9 Update PenSkin.js
changed clearColor on `_setCanvasSize`
2019-03-02 00:29:21 +01:00
peabrainiac
44d2fdeba8 Update PenSkin.js 2019-02-27 08:48:45 +01:00
Michael "Z" Goddard
e022222365 replace Silhouette._data with Silhouette._colorData
_colorData holds the same (and more) data that _data holds. Dropping
the _data array saves a lot of memory for a tiny performance
degradation in regards to touching object.
2019-02-22 17:08:53 -05:00
Michael "Z" Goddard
be5ab2e689 receive ImageData directly in Silhouette.update
Given ImageData we can skip drawing the input and getting image data.
This can help if update's color can also use the ImageData directly.
2019-02-22 17:08:04 -05:00
20 changed files with 553 additions and 443 deletions

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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 ||

View File

@@ -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
View 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;

View File

@@ -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>

View File

@@ -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);

View File

@@ -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
View File

@@ -0,0 +1,11 @@
body {
background: lightsteelblue;
}
canvas {
border: 3px dashed black;
}
#debug-canvas {
border-color: red;
}

View File

@@ -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

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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();
})();

View File

@@ -51,7 +51,7 @@ module.exports = [
new CopyWebpackPlugin([
{
context: 'src/playground',
from: '*.html'
from: '*.+(html|css)'
}
])
])