305 lines
6.5 KiB
JavaScript
305 lines
6.5 KiB
JavaScript
define(['./SVGUtilities'], (svg) => {
|
|
'use strict';
|
|
|
|
// Thanks, https://stackoverflow.com/a/9851769/1180785
|
|
const firefox = (typeof window.InstallTrigger !== 'undefined');
|
|
|
|
function fontDetails(attrs) {
|
|
const size = Number(attrs['font-size']);
|
|
const lineHeight = size * (Number(attrs['line-height']) || 1);
|
|
return {
|
|
size,
|
|
lineHeight,
|
|
};
|
|
}
|
|
|
|
function merge(state, newState) {
|
|
for(const k in state) {
|
|
if(state.hasOwnProperty(k)) {
|
|
if(newState[k] !== null && newState[k] !== undefined) {
|
|
state[k] = newState[k];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function populateSvgTextLine(node, formattedLine) {
|
|
if(!Array.isArray(formattedLine)) {
|
|
throw new Error('Invalid formatted text line: ' + formattedLine);
|
|
}
|
|
formattedLine.forEach(({text, attrs}) => {
|
|
const textNode = svg.makeText(text);
|
|
if(attrs) {
|
|
const span = svg.make('tspan', attrs, [textNode]);
|
|
node.appendChild(span);
|
|
} else {
|
|
node.appendChild(textNode);
|
|
}
|
|
});
|
|
}
|
|
|
|
const EMPTY = [];
|
|
|
|
class SVGTextBlock {
|
|
constructor(container, initialState = {}) {
|
|
this.container = container;
|
|
this.state = {
|
|
attrs: {},
|
|
formatted: EMPTY,
|
|
x: 0,
|
|
y: 0,
|
|
};
|
|
this.lines = [];
|
|
this.set(initialState);
|
|
}
|
|
|
|
_rebuildLines(count) {
|
|
if(count > this.lines.length) {
|
|
const attrs = Object.assign({
|
|
'x': this.state.x,
|
|
}, this.state.attrs);
|
|
|
|
while(this.lines.length < count) {
|
|
const node = svg.make('text', attrs);
|
|
this.container.appendChild(node);
|
|
this.lines.push({node, latest: ''});
|
|
}
|
|
} else {
|
|
while(this.lines.length > count) {
|
|
const {node} = this.lines.pop();
|
|
this.container.removeChild(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
_reset() {
|
|
this._rebuildLines(0);
|
|
}
|
|
|
|
_renderText() {
|
|
const {formatted} = this.state;
|
|
|
|
if(!formatted || !formatted.length) {
|
|
this._reset();
|
|
return;
|
|
}
|
|
if(!Array.isArray(formatted)) {
|
|
throw new Error('Invalid formatted text: ' + formatted);
|
|
}
|
|
|
|
this._rebuildLines(formatted.length);
|
|
|
|
this.lines.forEach((ln, i) => {
|
|
const id = JSON.stringify(formatted[i]);
|
|
if(id !== ln.latest) {
|
|
svg.empty(ln.node);
|
|
populateSvgTextLine(ln.node, formatted[i]);
|
|
ln.latest = id;
|
|
}
|
|
});
|
|
}
|
|
|
|
_updateX() {
|
|
this.lines.forEach(({node}) => {
|
|
node.setAttribute('x', this.state.x);
|
|
});
|
|
}
|
|
|
|
_updateY() {
|
|
const {size, lineHeight} = fontDetails(this.state.attrs);
|
|
this.lines.forEach(({node}, i) => {
|
|
node.setAttribute('y', this.state.y + i * lineHeight + size);
|
|
});
|
|
}
|
|
|
|
firstLine() {
|
|
if(this.lines.length > 0) {
|
|
return this.lines[0].node;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
set(newState) {
|
|
const oldState = Object.assign({}, this.state);
|
|
merge(this.state, newState);
|
|
|
|
if(this.state.attrs !== oldState.attrs) {
|
|
this._reset();
|
|
oldState.formatted = EMPTY;
|
|
}
|
|
|
|
const oldLines = this.lines.length;
|
|
|
|
if(this.state.formatted !== oldState.formatted) {
|
|
this._renderText();
|
|
}
|
|
|
|
if(this.state.x !== oldState.x) {
|
|
this._updateX();
|
|
}
|
|
|
|
if(this.state.y !== oldState.y || this.lines.length !== oldLines) {
|
|
this._updateY();
|
|
}
|
|
}
|
|
}
|
|
|
|
class SizeTester {
|
|
constructor(container) {
|
|
this.testers = svg.make('g', {
|
|
// Firefox fails to measure non-displayed text
|
|
'display': firefox ? 'block' : 'none',
|
|
'visibility': 'hidden',
|
|
});
|
|
this.container = container;
|
|
this.cache = new Map();
|
|
this.nodes = null;
|
|
}
|
|
|
|
_expectMeasure({attrs, formatted}) {
|
|
if(!formatted.length) {
|
|
return;
|
|
}
|
|
|
|
const attrKey = JSON.stringify(attrs);
|
|
let attrCache = this.cache.get(attrKey);
|
|
if(!attrCache) {
|
|
attrCache = {
|
|
attrs,
|
|
lines: new Map(),
|
|
};
|
|
this.cache.set(attrKey, attrCache);
|
|
}
|
|
|
|
formatted.forEach((line) => {
|
|
if(!line.length) {
|
|
return;
|
|
}
|
|
|
|
const labelKey = JSON.stringify(line);
|
|
if(!attrCache.lines.has(labelKey)) {
|
|
attrCache.lines.set(labelKey, {
|
|
formatted: line,
|
|
width: null,
|
|
});
|
|
}
|
|
});
|
|
|
|
return attrCache;
|
|
}
|
|
|
|
_measureHeight({attrs, formatted}) {
|
|
return formatted.length * fontDetails(attrs).lineHeight;
|
|
}
|
|
|
|
_measureLine(attrCache, line) {
|
|
if(!line.length) {
|
|
return 0;
|
|
}
|
|
|
|
const labelKey = JSON.stringify(line);
|
|
const cache = attrCache.lines.get(labelKey);
|
|
if(cache.width === null) {
|
|
window.console.warn('Performing unexpected measurement', line);
|
|
this.performMeasurements();
|
|
}
|
|
return cache.width;
|
|
}
|
|
|
|
_measureWidth(opts) {
|
|
if(!opts.formatted.length) {
|
|
return 0;
|
|
}
|
|
|
|
const attrCache = this._expectMeasure(opts);
|
|
|
|
return (opts.formatted
|
|
.map((line) => this._measureLine(attrCache, line))
|
|
.reduce((a, b) => Math.max(a, b), 0)
|
|
);
|
|
}
|
|
|
|
_getMeasurementOpts(attrs, formatted) {
|
|
if(!formatted) {
|
|
if(typeof attrs === 'object' && attrs.state) {
|
|
formatted = attrs.state.formatted || [];
|
|
attrs = attrs.state.attrs;
|
|
} else {
|
|
formatted = [];
|
|
}
|
|
} else if(!Array.isArray(formatted)) {
|
|
throw new Error('Invalid formatted text: ' + formatted);
|
|
}
|
|
return {attrs, formatted};
|
|
}
|
|
|
|
expectMeasure(attrs, formatted) {
|
|
const opts = this._getMeasurementOpts(attrs, formatted);
|
|
this._expectMeasure(opts);
|
|
}
|
|
|
|
performMeasurementsPre() {
|
|
this.nodes = [];
|
|
this.cache.forEach(({attrs, lines}) => {
|
|
lines.forEach((cacheLine) => {
|
|
if(cacheLine.width === null) {
|
|
const node = svg.make('text', attrs);
|
|
populateSvgTextLine(node, cacheLine.formatted);
|
|
this.testers.appendChild(node);
|
|
this.nodes.push({node, cacheLine});
|
|
}
|
|
});
|
|
});
|
|
|
|
if(this.nodes.length) {
|
|
this.container.appendChild(this.testers);
|
|
}
|
|
}
|
|
|
|
performMeasurementsAct() {
|
|
this.nodes.forEach(({node, cacheLine}) => {
|
|
cacheLine.width = node.getComputedTextLength();
|
|
});
|
|
}
|
|
|
|
performMeasurementsPost() {
|
|
if(this.nodes.length) {
|
|
this.container.removeChild(this.testers);
|
|
svg.empty(this.testers);
|
|
}
|
|
this.nodes = null;
|
|
}
|
|
|
|
performMeasurements() {
|
|
// getComputedTextLength forces a reflow, so we try to batch as
|
|
// many measurements as possible into a single DOM change
|
|
|
|
this.performMeasurementsPre();
|
|
this.performMeasurementsAct();
|
|
this.performMeasurementsPost();
|
|
}
|
|
|
|
measure(attrs, formatted) {
|
|
const opts = this._getMeasurementOpts(attrs, formatted);
|
|
return {
|
|
width: this._measureWidth(opts),
|
|
height: this._measureHeight(opts),
|
|
};
|
|
}
|
|
|
|
measureHeight(attrs, formatted) {
|
|
const opts = this._getMeasurementOpts(attrs, formatted);
|
|
return this._measureHeight(opts);
|
|
}
|
|
|
|
resetCache() {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
|
|
SVGTextBlock.SizeTester = SizeTester;
|
|
|
|
return SVGTextBlock;
|
|
});
|