Compare commits
1 Commits
revert-494
...
greenkeepe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fee47ea63 |
@@ -15,7 +15,6 @@
|
||||
"private": true,
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"template": "node_modules/docdash",
|
||||
"tutorials": "docs"
|
||||
"template": "node_modules/docdash"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
# Rectangle AABB Matrix
|
||||
|
||||
Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed by a model matrix.
|
||||
|
||||
-----
|
||||
|
||||
Every drawable is a 1 x 1 unit square that is rotated by its direction, scaled by its skin size and scale, and offset by its rotation center and position. The square representation is made up of 4 points that are transformed by the drawable properties. Often we want a shape that simplifies those 4 points into a non-rotated shape, a axis aligned bounding box.
|
||||
|
||||
One approach is to compare the x and y components of each transformed vector and find the minimum and maximum x component and the minimum and maximum y component.
|
||||
|
||||
We can start from this approach and determine an alternative one that prodcues the same output with less work.
|
||||
|
||||
Starting with transforming one point, here is a 3D point, `v`, transformation by a matrix, `m`.
|
||||
|
||||
```js
|
||||
const v0 = v[0];
|
||||
const v1 = v[1];
|
||||
const v2 = v[2];
|
||||
|
||||
const d = v0 * m[(0 * 4) + 3] + v1 * m[(1 * 4) + 3] + v2 * m[(2 * 4) + 3] + m[(3 * 4) + 3];
|
||||
dst[0] = (v0 * m[(0 * 4) + 0] + v1 * m[(1 * 4) + 0] + v2 * m[(2 * 4) + 0] + m[(3 * 4) + 0]) / d;
|
||||
dst[1] = (v0 * m[(0 * 4) + 1] + v1 * m[(1 * 4) + 1] + v2 * m[(2 * 4) + 1] + m[(3 * 4) + 1]) / d;
|
||||
dst[2] = (v0 * m[(0 * 4) + 2] + v1 * m[(1 * 4) + 2] + v2 * m[(2 * 4) + 2] + m[(3 * 4) + 2]) / d;
|
||||
```
|
||||
|
||||
As this is a 2D rectangle we can cancel out the third dimension, and the determinant, 'd'.
|
||||
|
||||
```js
|
||||
const v0 = v[0];
|
||||
const v1 = v[1];
|
||||
|
||||
dst = [
|
||||
v0 * m[(0 * 4) + 0] + v1 * m[(1 * 4) + 0] + m[(3 * 4) + 0,
|
||||
v0 * m[(0 * 4) + 1] + v1 * m[(1 * 4) + 1] + m[(3 * 4) + 1
|
||||
];
|
||||
```
|
||||
|
||||
Let's set the matrix points to shorter names for convenience.
|
||||
|
||||
```js
|
||||
const m00 = m[(0 * 4) + 0];
|
||||
const m01 = m[(0 * 4) + 1];
|
||||
const m10 = m[(1 * 4) + 0];
|
||||
const m11 = m[(1 * 4) + 1];
|
||||
const m30 = m[(3 * 4) + 0];
|
||||
const m31 = m[(3 * 4) + 1];
|
||||
```
|
||||
|
||||
We need 4 points with positive and negative 0.5 values so the square has sides of length 1.
|
||||
|
||||
```js
|
||||
let p = [0.5, 0.5];
|
||||
let q = [-0.5, 0.5];
|
||||
let r = [-0.5, -0.5];
|
||||
let s = [0.5, -0.5];
|
||||
```
|
||||
|
||||
Transform the points by the matrix.
|
||||
|
||||
```js
|
||||
p = [
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
0.5 * m01 + 0.5 * m11 + m31
|
||||
];
|
||||
q = [
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
0.5 * m01 + 0.5 * m11 + m31
|
||||
];
|
||||
r = [
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
-0.5 * m01 + -0.5 * m11 + m31
|
||||
];
|
||||
s = [
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m01 + -0.5 * m11 + m31
|
||||
];
|
||||
```
|
||||
|
||||
With 4 transformed points we can build the left, right, top, and bottom values for the Rectangle. Each will use the minimum or the maximum of one of the components of all points.
|
||||
|
||||
```js
|
||||
const left = Math.min(p[0], q[0], r[0], s[0]);
|
||||
const right = Math.max(p[0], q[0], r[0], s[0]);
|
||||
const top = Math.max(p[1], q[1], r[1], s[1]);
|
||||
const bottom = Math.min(p[1], q[1], r[1], s[1]);
|
||||
```
|
||||
|
||||
Fill those calls with the vector expressions.
|
||||
|
||||
```js
|
||||
const left = Math.min(
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
0.5 * m00 + -0.5 * m10 + m30
|
||||
);
|
||||
const right = Math.max(
|
||||
0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + 0.5 * m10 + m30,
|
||||
-0.5 * m00 + -0.5 * m10 + m30,
|
||||
0.5 * m00 + -0.5 * m10 + m30
|
||||
);
|
||||
const top = Math.max(
|
||||
0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + -0.5 * m11 + m31,
|
||||
0.5 * m01 + -0.5 * m11 + m31
|
||||
);
|
||||
const bottom = Math.min(
|
||||
0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + 0.5 * m11 + m31,
|
||||
-0.5 * m01 + -0.5 * m11 + m31,
|
||||
0.5 * m01 + -0.5 * m11 + m31
|
||||
);
|
||||
```
|
||||
|
||||
Pull out the `0.5 * m??` patterns.
|
||||
|
||||
```js
|
||||
const x0 = 0.5 * m00;
|
||||
const x1 = 0.5 * m10;
|
||||
const y0 = 0.5 * m01;
|
||||
const y1 = 0.5 * m11;
|
||||
|
||||
const left = Math.min(x0 + x1 + m30, -x0 + x1 + m30, -x0 + -x1 + m30, x0 + -x1 + m30);
|
||||
const right = Math.max(x0 + x1 + m30, -x0 + x1 + m30, -x0 + -x1 + m30, x0 + -x1 + m30);
|
||||
const top = Math.max(y0 + y1 + m31, -y0 + y1 + m31, -y0 + -y1 + m31, y0 + -y1 + m31);
|
||||
const bottom = Math.min(y0 + y1 + m31, -y0 + y1 + m31, -y0 + -y1 + m31, y0 + -y1 + m31);
|
||||
```
|
||||
|
||||
Now each argument for the min and max calls take an expression like `(a * x0 + b * x1 + m3?)`. As each expression has the x0, x1, and m3? variables we can split the min and max calls on the addition operators. Each new call has all the coefficients of that variable.
|
||||
|
||||
```js
|
||||
const left = Math.min(x0, -x0) + Math.min(x1, -x1) + Math.min(m30, m30);
|
||||
const right = Math.max(x0, -x0) + Math.max(x1, -x1) + Math.max(m30, m30);
|
||||
const top = Math.max(y0, -y0) + Math.max(y1, -y1) + Math.max(m31, m31);
|
||||
const bottom = Math.min(y0, -y0) + Math.min(y1, -y1) + Math.min(m31, m31);
|
||||
```
|
||||
|
||||
The min or max of two copies of the same value will just be that value.
|
||||
|
||||
```js
|
||||
const left = Math.min(x0, -x0) + Math.min(x1, -x1) + m30;
|
||||
const right = Math.max(x0, -x0) + Math.max(x1, -x1) + m30;
|
||||
const top = Math.max(y0, -y0) + Math.max(y1, -y1) + m31;
|
||||
const bottom = Math.min(y0, -y0) + Math.min(y1, -y1) + m31;
|
||||
```
|
||||
|
||||
The max of a negative and positive variable will be the absolute value of that variable. The min of a negative and positive variable will the negated absolute value of that variable.
|
||||
|
||||
```js
|
||||
const left = -Math.abs(x0) + -Math.abs(x1) + m30;
|
||||
const right = Math.abs(x0) + Math.abs(x1) + m30;
|
||||
const top = Math.abs(y0) + Math.abs(y1) + m31;
|
||||
const bottom = -Math.abs(y0) + -Math.abs(y1) + m31;
|
||||
```
|
||||
|
||||
Pulling out the negations of the absolute values, left and right as well as top and bottom are the positive or negative sum of the absolute value of the saled and rotated unit value.
|
||||
|
||||
```js
|
||||
const left = -(Math.abs(x0) + Math.abs(x1)) + m30;
|
||||
const right = Math.abs(x0) + Math.abs(x1) + m30;
|
||||
const top = Math.abs(y0) + Math.abs(y1) + m31;
|
||||
const bottom = -(Math.abs(y0) + Math.abs(y1)) + m31;
|
||||
```
|
||||
|
||||
We call pull out those sums and use them twice.
|
||||
|
||||
```js
|
||||
const x = Math.abs(x0) + Math.abs(x1);
|
||||
const y = Math.abs(y0) + Math.abs(y1);
|
||||
|
||||
const left = -x + m30;
|
||||
const right = x + m30;
|
||||
const top = y + m31;
|
||||
const bottom = -y + m31;
|
||||
```
|
||||
|
||||
This lets us arrive at our goal. Inlining some of our variables we get this block that will initialize a Rectangle to a unit square transformed by a matrix.
|
||||
|
||||
```js
|
||||
const m30 = m[(3 * 4) + 0];
|
||||
const m31 = m[(3 * 4) + 1];
|
||||
|
||||
const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]);
|
||||
const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]);
|
||||
|
||||
const left = -x + m30;
|
||||
const right = x + m30;
|
||||
const top = y + m31;
|
||||
const bottom = -y + m31;
|
||||
```
|
||||
@@ -38,7 +38,7 @@
|
||||
"jsdoc": "^3.5.5",
|
||||
"json": "^9.0.4",
|
||||
"scratch-vm": "0.2.0-prerelease.20190207224121",
|
||||
"tap": "^11.0.0",
|
||||
"tap": "^13.1.2",
|
||||
"travis-after-all": "^1.4.4",
|
||||
"uglifyjs-webpack-plugin": "^1.2.5",
|
||||
"webpack": "^4.8.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"minilog": "3.1.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"scratch-storage": "^1.0.0",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20190715153806",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20190419183947",
|
||||
"twgl.js": "4.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +62,10 @@ class BitmapSkin extends Skin {
|
||||
/**
|
||||
* Get the bounds of the drawable for determining its fenced position.
|
||||
* @param {Array<number>} drawable - The Drawable instance this skin is using.
|
||||
* @param {?Rectangle} result - Optional destination for bounds calculation.
|
||||
* @return {!Rectangle} The drawable's bounds. For compatibility with Scratch 2, we always use getAABB for bitmaps.
|
||||
*/
|
||||
getFenceBounds (drawable, result) {
|
||||
return drawable.getAABB(result);
|
||||
getFenceBounds (drawable) {
|
||||
return drawable.getAABB();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -451,10 +451,9 @@ class Drawable {
|
||||
* This function applies the transform matrix to the known convex hull,
|
||||
* and then finds the minimum box along the axes.
|
||||
* Before calling this, ensure the renderer has updated convex hull points.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Bounds for a tight box around the Drawable.
|
||||
*/
|
||||
getBounds (result) {
|
||||
getBounds () {
|
||||
if (this.needsConvexHullPoints()) {
|
||||
throw new Error('Needs updated convex hull points before bounds calculation.');
|
||||
}
|
||||
@@ -463,19 +462,18 @@ class Drawable {
|
||||
}
|
||||
const transformedHullPoints = this._getTransformedHullPoints();
|
||||
// Search through transformed points to generate box on axes.
|
||||
result = result || new Rectangle();
|
||||
result.initFromPointsAABB(transformedHullPoints);
|
||||
return result;
|
||||
const bounds = new Rectangle();
|
||||
bounds.initFromPointsAABB(transformedHullPoints);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the precise bounds for the upper 8px slice of the Drawable.
|
||||
* Used for calculating where to position a text bubble.
|
||||
* Before calling this, ensure the renderer has updated convex hull points.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Bounds for a tight box around a slice of the Drawable.
|
||||
*/
|
||||
getBoundsForBubble (result) {
|
||||
getBoundsForBubble () {
|
||||
if (this.needsConvexHullPoints()) {
|
||||
throw new Error('Needs updated convex hull points before bubble bounds calculation.');
|
||||
}
|
||||
@@ -487,9 +485,9 @@ class Drawable {
|
||||
const maxY = Math.max.apply(null, transformedHullPoints.map(p => p[1]));
|
||||
const filteredHullPoints = transformedHullPoints.filter(p => p[1] > maxY - slice);
|
||||
// Search through filtered points to generate box on axes.
|
||||
result = result || new Rectangle();
|
||||
result.initFromPointsAABB(filteredHullPoints);
|
||||
return result;
|
||||
const bounds = new Rectangle();
|
||||
bounds.initFromPointsAABB(filteredHullPoints);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,32 +497,35 @@ class Drawable {
|
||||
* which is tightly snapped to account for a Drawable's transparent regions.
|
||||
* `getAABB` returns a much less accurate bounding box, but will be much
|
||||
* faster to calculate so may be desired for quick checks/optimizations.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Rough axis-aligned bounding box for Drawable.
|
||||
*/
|
||||
getAABB (result) {
|
||||
getAABB () {
|
||||
if (this._transformDirty) {
|
||||
this._calculateTransform();
|
||||
}
|
||||
const tm = this._uniforms.u_modelMatrix;
|
||||
result = result || new Rectangle();
|
||||
result.initFromModelMatrix(tm);
|
||||
return result;
|
||||
const bounds = new Rectangle();
|
||||
bounds.initFromPointsAABB([
|
||||
twgl.m4.transformPoint(tm, [-0.5, -0.5, 0]),
|
||||
twgl.m4.transformPoint(tm, [0.5, -0.5, 0]),
|
||||
twgl.m4.transformPoint(tm, [-0.5, 0.5, 0]),
|
||||
twgl.m4.transformPoint(tm, [0.5, 0.5, 0])
|
||||
]);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the best Drawable bounds possible without performing graphics queries.
|
||||
* I.e., returns the tight bounding box when the convex hull points are already
|
||||
* known, but otherwise return the rough AABB of the Drawable.
|
||||
* @param {?Rectangle} result optional destination for bounds calculation
|
||||
* @return {!Rectangle} Bounds for the Drawable.
|
||||
*/
|
||||
getFastBounds (result) {
|
||||
getFastBounds () {
|
||||
this.updateMatrix();
|
||||
if (!this.needsConvexHullPoints()) {
|
||||
return this.getBounds(result);
|
||||
return this.getBounds();
|
||||
}
|
||||
return this.getAABB(result);
|
||||
return this.getAABB();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,31 +54,6 @@ class Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a Rectangle to a 1 unit square centered at 0 x 0 transformed
|
||||
* by a model matrix.
|
||||
* @param {Array.<number>} m A 4x4 matrix to transform the rectangle by.
|
||||
* @tutorial Rectangle-AABB-Matrix
|
||||
*/
|
||||
initFromModelMatrix (m) {
|
||||
// In 2D space, we will soon use the 2x2 "top left" scale and rotation
|
||||
// submatrix, while we store and the 1x2 "top right" that position
|
||||
// vector.
|
||||
const m30 = m[(3 * 4) + 0];
|
||||
const m31 = m[(3 * 4) + 1];
|
||||
|
||||
// "Transform" a (0.5, 0.5) vector by the scale and rotation matrix but
|
||||
// sum the absolute of each component instead of use the signed values.
|
||||
const x = Math.abs(0.5 * m[(0 * 4) + 0]) + Math.abs(0.5 * m[(1 * 4) + 0]);
|
||||
const y = Math.abs(0.5 * m[(0 * 4) + 1]) + Math.abs(0.5 * m[(1 * 4) + 1]);
|
||||
|
||||
// And adding them to the position components initializes our Rectangle.
|
||||
this.left = -x + m30;
|
||||
this.right = x + m30;
|
||||
this.top = y + m31;
|
||||
this.bottom = -y + m31;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this Rectangle intersects some other.
|
||||
* Note that this is a comparison assuming the Rectangle was
|
||||
|
||||
@@ -10,13 +10,12 @@ const PenSkin = require('./PenSkin');
|
||||
const RenderConstants = require('./RenderConstants');
|
||||
const ShaderManager = require('./ShaderManager');
|
||||
const SVGSkin = require('./SVGSkin');
|
||||
const TextBubbleSkin = require('./TextBubbleSkin');
|
||||
const SVGTextBubble = require('./util/svg-text-bubble');
|
||||
const EffectTransform = require('./EffectTransform');
|
||||
const log = require('./util/log');
|
||||
|
||||
const __isTouchingDrawablesPoint = twgl.v3.create();
|
||||
const __candidatesBounds = new Rectangle();
|
||||
const __fenceBounds = new Rectangle();
|
||||
const __touchingColor = new Uint8ClampedArray(4);
|
||||
const __blendColor = new Uint8ClampedArray(4);
|
||||
|
||||
@@ -185,6 +184,8 @@ class RenderWebGL extends EventEmitter {
|
||||
/** @type {Array.<snapshotCallback>} */
|
||||
this._snapshotCallbacks = [];
|
||||
|
||||
this._svgTextBubble = new SVGTextBubble();
|
||||
|
||||
this._createGeometry();
|
||||
|
||||
this.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged);
|
||||
@@ -342,11 +343,8 @@ class RenderWebGL extends EventEmitter {
|
||||
* @returns {!int} the ID for the new skin.
|
||||
*/
|
||||
createTextSkin (type, text, pointsLeft) {
|
||||
const skinId = this._nextSkinId++;
|
||||
const newSkin = new TextBubbleSkin(skinId, this);
|
||||
newSkin.setTextBubble(type, text, pointsLeft);
|
||||
this._allSkins[skinId] = newSkin;
|
||||
return skinId;
|
||||
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
|
||||
return this.createSVGSkin(bubbleSvg, [0, 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,14 +407,8 @@ class RenderWebGL extends EventEmitter {
|
||||
* @param {!boolean} pointsLeft - which side the bubble is pointing.
|
||||
*/
|
||||
updateTextSkin (skinId, type, text, pointsLeft) {
|
||||
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);
|
||||
const bubbleSvg = this._svgTextBubble.buildString(type, text, pointsLeft);
|
||||
this.updateSVGSkin(skinId, bubbleSvg, [0, 0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1358,7 +1350,7 @@ class RenderWebGL extends EventEmitter {
|
||||
|
||||
const dx = x - drawable._position[0];
|
||||
const dy = y - drawable._position[1];
|
||||
const aabb = drawable._skin.getFenceBounds(drawable, __fenceBounds);
|
||||
const aabb = drawable._skin.getFenceBounds(drawable);
|
||||
const inset = Math.floor(Math.min(aabb.width, aabb.height) / 2);
|
||||
|
||||
const sx = this._xRight - Math.min(FENCE_WIDTH, inset);
|
||||
@@ -1628,14 +1620,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,11 +146,10 @@ class Skin extends EventEmitter {
|
||||
/**
|
||||
* Get the bounds of the drawable for determining its fenced position.
|
||||
* @param {Array<number>} drawable - The Drawable instance this skin is using.
|
||||
* @param {?Rectangle} result - Optional destination for bounds calculation.
|
||||
* @return {!Rectangle} The drawable's bounds.
|
||||
*/
|
||||
getFenceBounds (drawable, result) {
|
||||
return drawable.getFastBounds(result);
|
||||
getFenceBounds (drawable) {
|
||||
return drawable.getFastBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
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,11 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Scratch WebGL rendering demo</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
<style>
|
||||
#scratch-stage { width: 480px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="scratch-stage" width="10" height="10"></canvas>
|
||||
<canvas id="debug-canvas" width="10" height="10"></canvas>
|
||||
<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>
|
||||
<p>
|
||||
<label for="fudgeproperty">Property to tweak:</label>
|
||||
<select id="fudgeproperty">
|
||||
@@ -16,7 +18,6 @@
|
||||
<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>
|
||||
@@ -29,12 +30,8 @@
|
||||
<input type="range" id="fudge" style="width:50%" value="90" min="-90" max="270" step="any">
|
||||
</p>
|
||||
<p>
|
||||
<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">
|
||||
<label for="fudgeMin">Min:</label><input id="fudgeMin" type="number">
|
||||
<label for="fudgeMax">Max:</label><input id="fudgeMax" type="number">
|
||||
</p>
|
||||
<script src="playground.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
const ScratchRender = require('../RenderWebGL');
|
||||
const getMousePosition = require('./getMousePosition');
|
||||
|
||||
const canvas = document.getElementById('scratch-stage');
|
||||
let fudge = 90;
|
||||
const renderer = new ScratchRender(canvas);
|
||||
var canvas = document.getElementById('scratch-stage');
|
||||
var fudge = 90;
|
||||
var renderer = new ScratchRender(canvas);
|
||||
renderer.setLayerGroupOrdering(['group1']);
|
||||
|
||||
const drawableID = renderer.createDrawable('group1');
|
||||
var drawableID = renderer.createDrawable('group1');
|
||||
renderer.updateDrawableProperties(drawableID, {
|
||||
position: [0, 0],
|
||||
scale: [100, 100],
|
||||
direction: 90
|
||||
});
|
||||
|
||||
const WantedSkinType = {
|
||||
bitmap: 'bitmap',
|
||||
vector: 'vector',
|
||||
pen: 'pen'
|
||||
};
|
||||
|
||||
const drawableID2 = renderer.createDrawable('group1');
|
||||
const wantedSkin = WantedSkinType.vector;
|
||||
var drawableID2 = renderer.createDrawable('group1');
|
||||
var wantBitmapSkin = false;
|
||||
|
||||
// Bitmap (squirrel)
|
||||
const image = new Image();
|
||||
var image = new Image();
|
||||
image.addEventListener('load', () => {
|
||||
const bitmapSkinId = renderer.createBitmapSkin(image);
|
||||
if (wantedSkin === WantedSkinType.bitmap) {
|
||||
var bitmapSkinId = renderer.createBitmapSkin(image);
|
||||
if (wantBitmapSkin) {
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: bitmapSkinId
|
||||
});
|
||||
@@ -36,83 +30,44 @@ image.crossOrigin = 'anonymous';
|
||||
image.src = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/';
|
||||
|
||||
// SVG (cat 1-a)
|
||||
const xhr = new XMLHttpRequest();
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('load', function () {
|
||||
const skinId = renderer.createSVGSkin(xhr.responseText);
|
||||
if (wantedSkin === WantedSkinType.vector) {
|
||||
var skinId = renderer.createSVGSkin(xhr.responseText);
|
||||
if (!wantBitmapSkin) {
|
||||
renderer.updateDrawableProperties(drawableID2, {
|
||||
skinId: skinId
|
||||
});
|
||||
}
|
||||
});
|
||||
xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/b7853f557e4426412e64bb3da6531a99.svg/get/');
|
||||
xhr.open('GET', 'https://cdn.assets.scratch.mit.edu/internalapi/asset/f88bf1935daea28f8ca098462a31dbb0.svg/get/');
|
||||
xhr.send();
|
||||
|
||||
if (wantedSkin === WantedSkinType.pen) {
|
||||
const penSkinID = renderer.createPenSkin();
|
||||
var posX = 0;
|
||||
var posY = 0;
|
||||
var scaleX = 100;
|
||||
var scaleY = 100;
|
||||
var fudgeProperty = 'posx';
|
||||
|
||||
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 fudgePropertyInput = document.getElementById('fudgeproperty');
|
||||
fudgePropertyInput.addEventListener('change', event => {
|
||||
fudgeProperty = event.target.value;
|
||||
});
|
||||
|
||||
const fudgeInput = document.getElementById('fudge');
|
||||
const fudgePropertyInput = document.getElementById('fudgeproperty');
|
||||
|
||||
const fudgeMinInput = document.getElementById('fudgeMin');
|
||||
const fudgeMaxInput = document.getElementById('fudgeMax');
|
||||
|
||||
/* eslint require-jsdoc: 0 */
|
||||
const updateFudgeProperty = event => {
|
||||
fudgeProperty = event.target.value;
|
||||
};
|
||||
|
||||
const updateFudgeMin = event => {
|
||||
fudgeMinInput.addEventListener('change', event => {
|
||||
fudgeInput.min = event.target.valueAsNumber;
|
||||
};
|
||||
});
|
||||
|
||||
const updateFudgeMax = event => {
|
||||
const fudgeMaxInput = document.getElementById('fudgeMax');
|
||||
fudgeMaxInput.addEventListener('change', 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;
|
||||
const props = {};
|
||||
var props = {};
|
||||
switch (fudgeProperty) {
|
||||
case 'posx':
|
||||
props.position = [fudge, posY];
|
||||
@@ -133,11 +88,6 @@ 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;
|
||||
@@ -162,28 +112,17 @@ 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 => {
|
||||
const mousePos = getMousePosition(event, canvas);
|
||||
var mousePos = getMousePosition(event, canvas);
|
||||
renderer.extractColor(mousePos.x, mousePos.y, 30);
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', event => {
|
||||
const mousePos = getMousePosition(event, canvas);
|
||||
const pickID = renderer.pick(mousePos.x, mousePos.y);
|
||||
var mousePos = getMousePosition(event, canvas);
|
||||
var 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));
|
||||
@@ -198,5 +137,5 @@ const drawStep = function () {
|
||||
};
|
||||
drawStep();
|
||||
|
||||
const debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas');
|
||||
var debugCanvas = /** @type {canvas} */ document.getElementById('debug-canvas');
|
||||
renderer.setDebugCanvas(debugCanvas);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<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 */
|
||||
@@ -12,6 +11,8 @@
|
||||
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+ */
|
||||
@@ -22,7 +23,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body style="background: lightsteelblue">
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend>Query Canvases</legend>
|
||||
@@ -62,7 +63,7 @@
|
||||
<input id="cursorY" type="range" orient="vertical" step="0.25" value="0" />
|
||||
</td>
|
||||
<td>
|
||||
<canvas id="renderCanvas" width="480" height="360"></canvas>
|
||||
<canvas id="renderCanvas" width="480" height="360" style="border:3px dashed black"></canvas>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
body {
|
||||
background: lightsteelblue;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px dashed black;
|
||||
}
|
||||
|
||||
#debug-canvas {
|
||||
border-color: red;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
205
src/util/svg-text-bubble.js
Normal file
205
src/util/svg-text-bubble.js
Normal file
@@ -0,0 +1,205 @@
|
||||
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;
|
||||
127
src/util/svg-text-wrapper.js
Normal file
127
src/util/svg-text-wrapper.js
Normal file
@@ -0,0 +1,127 @@
|
||||
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 #29: http://unicode.org/reports/tr29/
|
||||
* - Unicode Standard Annex #39: 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, Promise */
|
||||
/* global vm, render, Promise */
|
||||
const {Chromeless} = require('chromeless');
|
||||
const test = require('tap').test;
|
||||
const path = require('path');
|
||||
@@ -101,6 +101,16 @@ 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())
|
||||
@@ -110,6 +120,8 @@ const testFile = file => test(file, 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|css)'
|
||||
from: '*.html'
|
||||
}
|
||||
])
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user