Add utils for text wrapping, originally by CWF
This commit is contained in:
@@ -34,9 +34,12 @@
|
||||
"eslint": "^4.6.1",
|
||||
"eslint-config-scratch": "^4.0.0",
|
||||
"gh-pages": "^1.0.0",
|
||||
"grapheme-breaker": "0.3.2",
|
||||
"hull.js": "0.2.10",
|
||||
"ify-loader": "1.0.4",
|
||||
"jsdoc": "^3.5.5",
|
||||
"json": "^9.0.4",
|
||||
"linebreak": "0.3.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"scratch-render-fonts": "git+https://github.com/LLK/scratch-render-fonts.git",
|
||||
"tap": "^10.3.0",
|
||||
|
||||
104
src/util/svg-text-wrapper.js
Normal file
104
src/util/svg-text-wrapper.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const TextWrapper = require('./text-wrapper');
|
||||
|
||||
/**
|
||||
* Measure text by using a hidden SVG attached to the DOM.
|
||||
* For use with TextWrapper.
|
||||
*/
|
||||
class SVGMeasurementProvider {
|
||||
constructor () {
|
||||
this._svgRoot = null;
|
||||
this._cache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = document.createElementNS(svgNamespace, 'text');
|
||||
|
||||
// Normalize text attributes to match what the svg-renderer does.
|
||||
// @TODO This code should be shared with the svg-renderer.
|
||||
svgText.setAttribute('alignment-baseline', 'text-before-edge');
|
||||
svgText.setAttribute('font-size', '14');
|
||||
svgText.setAttribute('font-family', 'Helvetica');
|
||||
|
||||
// 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 {
|
||||
constructor () {
|
||||
super(new SVGMeasurementProvider());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SVGTextWrapper;
|
||||
113
src/util/text-wrapper.js
Normal file
113
src/util/text-wrapper.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const LineBreaker = require('linebreak');
|
||||
const GraphemeBreaker = require('grapheme-breaker');
|
||||
|
||||
/**
|
||||
* Tell this text wrapper to use a specific measurement provider.
|
||||
* @typedef {object} MeasurementProvider - the new measurement provider.
|
||||
* @property {Function} beginMeasurementSession - this will be called before a batch of measurements are made.
|
||||
* Optionally, this function may return an object to be provided to the endMeasurementSession function.
|
||||
* @property {Function} measureText - this will be called each time a piece of text must be measured.
|
||||
* @property {Function} endMeasurementSession - this will be called after a batch of measurements is finished.
|
||||
* It will be passed whatever value beginMeasurementSession returned, if any.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility to wrap text across several lines, respecting Unicode grapheme clusters and, when possible, Unicode line
|
||||
* break opportunities.
|
||||
* Reference material:
|
||||
* - Unicode Standard Annex #14: http://unicode.org/reports/tr14/
|
||||
* - 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 {
|
||||
|
||||
/**
|
||||
* Construct a text wrapper which will measure text using the specified measurement provider.
|
||||
* @param {MeasurementProvider} measurementProvider - a helper object to provide text measurement services.
|
||||
*/
|
||||
constructor (measurementProvider) {
|
||||
this._measurementProvider = measurementProvider;
|
||||
this._cache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {Array.<string>} an array containing the wrapped lines of text.
|
||||
*/
|
||||
wrapText (maxWidth, text) {
|
||||
// Normalize to canonical composition (see Unicode Standard Annex (UAX) #15)
|
||||
text = text.normalize();
|
||||
|
||||
const cacheKey = `${maxWidth}-${text}`;
|
||||
if (this._cache[cacheKey]) {
|
||||
return this._cache[cacheKey];
|
||||
}
|
||||
|
||||
const measurementSession = this._measurementProvider.beginMeasurementSession();
|
||||
|
||||
const breaker = new LineBreaker(text);
|
||||
let lastPosition = 0;
|
||||
let nextBreak;
|
||||
let currentLine = null;
|
||||
const lines = [];
|
||||
|
||||
while ((nextBreak = breaker.nextBreak())) {
|
||||
const word = text.slice(lastPosition, nextBreak.position).replace(/\n+$/, '');
|
||||
|
||||
let proposedLine = (currentLine || '').concat(word);
|
||||
let proposedLineWidth = this._measurementProvider.measureText(proposedLine);
|
||||
|
||||
if (proposedLineWidth > maxWidth) {
|
||||
// The next word won't fit on this line. Will it fit on a line by itself?
|
||||
const wordWidth = this._measurementProvider.measureText(word);
|
||||
if (wordWidth > maxWidth) {
|
||||
// The next word can't even fit on a line by itself. Consume it one grapheme cluster at a time.
|
||||
let lastCluster = 0;
|
||||
let nextCluster;
|
||||
while (lastCluster !== (nextCluster = GraphemeBreaker.nextBreak(word, lastCluster))) {
|
||||
const cluster = word.substring(lastCluster, nextCluster);
|
||||
proposedLine = (currentLine || '').concat(cluster);
|
||||
proposedLineWidth = this._measurementProvider.measureText(proposedLine);
|
||||
if ((currentLine === null) || (proposedLineWidth <= maxWidth)) {
|
||||
// first cluster of a new line or the cluster fits
|
||||
currentLine = proposedLine;
|
||||
} else {
|
||||
// no more can fit
|
||||
lines.push(currentLine);
|
||||
currentLine = cluster;
|
||||
}
|
||||
lastCluster = nextCluster;
|
||||
}
|
||||
} else {
|
||||
// The next word can fit on the next line. Finish the current line and move on.
|
||||
if (currentLine !== null) lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
} else {
|
||||
// The next word fits on this line. Just keep going.
|
||||
currentLine = proposedLine;
|
||||
}
|
||||
|
||||
// Did we find a \n or similar?
|
||||
if (nextBreak.required) {
|
||||
if (currentLine !== null) lines.push(currentLine);
|
||||
currentLine = null;
|
||||
}
|
||||
|
||||
lastPosition = nextBreak.position;
|
||||
}
|
||||
|
||||
currentLine = currentLine || '';
|
||||
if (currentLine.length > 0 || lines.length === 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
this._cache[cacheKey] = lines;
|
||||
this._measurementProvider.endMeasurementSession(measurementSession);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TextWrapper;
|
||||
@@ -20,6 +20,10 @@ const base = {
|
||||
options: {
|
||||
presets: ['es2015']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /node_modules\/(linebreak|grapheme-breaker)\/.*\.js$/,
|
||||
loader: 'ify-loader'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user