Reduce number of forced reflows when rendering

This commit is contained in:
David Evans 2018-02-08 23:13:19 +00:00
parent d283fe158c
commit ab3d67f313
16 changed files with 262 additions and 133 deletions

View File

@ -3387,13 +3387,17 @@ define('svg/SVGUtilities',[],() => {
return document.createTextNode(text); return document.createTextNode(text);
} }
function make(type, attrs = {}, children = []) { function setAttributes(target, attrs) {
const o = document.createElementNS(NS, type);
for(const k in attrs) { for(const k in attrs) {
if(attrs.hasOwnProperty(k)) { if(attrs.hasOwnProperty(k)) {
o.setAttribute(k, attrs[k]); target.setAttribute(k, attrs[k]);
} }
} }
}
function make(type, attrs = {}, children = []) {
const o = document.createElementNS(NS, type);
setAttributes(o, attrs);
for(const c of children) { for(const c of children) {
o.appendChild(c); o.appendChild(c);
} }
@ -3417,6 +3421,7 @@ define('svg/SVGUtilities',[],() => {
makeText, makeText,
make, make,
makeContainer, makeContainer,
setAttributes,
empty, empty,
}; };
}); });
@ -3461,6 +3466,27 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
}); });
} }
function measureLine(tester, line) {
if(!line.length) {
return 0;
}
const labelKey = JSON.stringify(line);
const knownWidth = tester.widths.get(labelKey);
if(knownWidth !== undefined) {
return knownWidth;
}
// getComputedTextLength forces a reflow, so only call it if nothing
// else can tell us the length
svg.empty(tester.node);
populateSvgTextLine(tester.node, line);
const width = tester.node.getComputedTextLength();
tester.widths.set(labelKey, width);
return width;
}
const EMPTY = []; const EMPTY = [];
class SVGTextBlock { class SVGTextBlock {
@ -3472,8 +3498,6 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
x: 0, x: 0,
y: 0, y: 0,
}; };
this.width = 0;
this.height = 0;
this.lines = []; this.lines = [];
this.set(initialState); this.set(initialState);
} }
@ -3499,8 +3523,6 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
_reset() { _reset() {
this._rebuildLines(0); this._rebuildLines(0);
this.width = 0;
this.height = 0;
} }
_renderText() { _renderText() {
@ -3516,7 +3538,6 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
this._rebuildLines(formatted.length); this._rebuildLines(formatted.length);
let maxWidth = 0;
this.lines.forEach((ln, i) => { this.lines.forEach((ln, i) => {
const id = JSON.stringify(formatted[i]); const id = JSON.stringify(formatted[i]);
if(id !== ln.latest) { if(id !== ln.latest) {
@ -3524,9 +3545,7 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
populateSvgTextLine(ln.node, formatted[i]); populateSvgTextLine(ln.node, formatted[i]);
ln.latest = id; ln.latest = id;
} }
maxWidth = Math.max(maxWidth, ln.node.getComputedTextLength());
}); });
this.width = maxWidth;
} }
_updateX() { _updateX() {
@ -3540,7 +3559,6 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
this.lines.forEach(({node}, i) => { this.lines.forEach(({node}, i) => {
node.setAttribute('y', this.state.y + i * lineHeight + size); node.setAttribute('y', this.state.y + i * lineHeight + size);
}); });
this.height = lineHeight * this.lines.length;
} }
firstLine() { firstLine() {
@ -3587,47 +3605,62 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
this.cache = new Map(); this.cache = new Map();
} }
measure(attrs, formatted) { _measureHeight({attrs, formatted}) {
if(!formatted || !formatted.length) { return formatted.length * fontDetails(attrs).lineHeight;
return {width: 0, height: 0};
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
} }
let tester = this.cache.get(attrs); _measureWidth({attrs, formatted}) {
if(!formatted.length) {
return 0;
}
const attrKey = JSON.stringify(attrs);
let tester = this.cache.get(attrKey);
if(!tester) { if(!tester) {
tester = svg.make('text', attrs); const node = svg.make('text', attrs);
this.testers.appendChild(tester); this.testers.appendChild(node);
this.cache.set(attrs, tester); tester = {
node,
widths: new Map(),
};
this.cache.set(attrKey, tester);
} }
if(!this.testers.parentNode) { if(!this.testers.parentNode) {
this.container.appendChild(this.testers); this.container.appendChild(this.testers);
} }
let width = 0; return (formatted
formatted.forEach((line) => { .map((line) => measureLine(tester, line))
svg.empty(tester); .reduce((a, b) => Math.max(a, b), 0)
populateSvgTextLine(tester, line); );
width = Math.max(width, tester.getComputedTextLength()); }
});
_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};
}
measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return { return {
width, width: this._measureWidth(opts),
height: formatted.length * fontDetails(attrs).lineHeight, height: this._measureHeight(opts),
}; };
} }
measureHeight(attrs, formatted) { measureHeight(attrs, formatted) {
if(!formatted) { const opts = this._getMeasurementOpts(attrs, formatted);
return 0; return this._measureHeight(opts);
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
}
return formatted.length * fontDetails(attrs).lineHeight;
} }
resetCache() { resetCache() {
@ -3838,6 +3871,7 @@ define('svg/SVGShapes',[
labelLayer, labelLayer,
boxRenderer = null, boxRenderer = null,
SVGTextBlockClass = SVGTextBlock, SVGTextBlockClass = SVGTextBlock,
textSizer,
}) { }) {
if(!formatted || !formatted.length) { if(!formatted || !formatted.length) {
return {width: 0, height: 0, label: null, box: null}; return {width: 0, height: 0, label: null, box: null};
@ -3852,20 +3886,21 @@ define('svg/SVGShapes',[
y: y + padding.top, y: y + padding.top,
}); });
const width = (label.width + padding.left + padding.right); const size = textSizer.measure(label);
const height = (label.height + padding.top + padding.bottom); const width = (size.width + padding.left + padding.right);
const height = (size.height + padding.top + padding.bottom);
let box = null; let box = null;
if(boxRenderer) { if(boxRenderer) {
box = boxRenderer({ box = boxRenderer({
'x': anchorX - label.width * shift - padding.left, 'x': anchorX - size.width * shift - padding.left,
'y': y, 'y': y,
'width': width, 'width': width,
'height': height, 'height': height,
}); });
} else { } else {
box = renderBox(boxAttrs, { box = renderBox(boxAttrs, {
'x': anchorX - label.width * shift - padding.left, 'x': anchorX - size.width * shift - padding.left,
'y': y, 'y': y,
'width': width, 'width': width,
'height': height, 'height': height,
@ -4064,6 +4099,7 @@ define('sequence/components/Block',[
boxLayer: blockInfo.hold, boxLayer: blockInfo.hold,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
const labelRender = SVGShapes.renderBoxedText(label, { const labelRender = SVGShapes.renderBoxedText(label, {
@ -4075,6 +4111,7 @@ define('sequence/components/Block',[
boxLayer: env.lineMaskLayer, boxLayer: env.lineMaskLayer,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
const labelHeight = Math.max( const labelHeight = Math.max(
@ -4434,6 +4471,7 @@ define('sequence/components/AgentCap',[
boxLayer: clickable, boxLayer: clickable,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
clickable.insertBefore(svg.make('rect', { clickable.insertBefore(svg.make('rect', {
'x': x - text.width / 2, 'x': x - text.width / 2,
@ -5003,6 +5041,7 @@ define('sequence/components/Connect',[
boxLayer: env.lineMaskLayer, boxLayer: env.lineMaskLayer,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
const labelW = (label ? ( const labelW = (label ? (
renderedText.width + renderedText.width +
@ -5129,6 +5168,7 @@ define('sequence/components/Connect',[
boxLayer: env.lineMaskLayer, boxLayer: env.lineMaskLayer,
labelLayer, labelLayer,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
} }
@ -5348,15 +5388,16 @@ define('sequence/components/Note',['./BaseComponent', 'svg/SVGUtilities'], (Base
formatted: label, formatted: label,
y, y,
}); });
const size = env.textSizer.measure(labelNode);
const fullW = ( const fullW = (
labelNode.width + size.width +
config.padding.left + config.padding.left +
config.padding.right config.padding.right
); );
const fullH = ( const fullH = (
config.padding.top + config.padding.top +
labelNode.height + size.height +
config.padding.bottom config.padding.bottom
); );
if(x0 === null && xMid !== null) { if(x0 === null && xMid !== null) {
@ -5638,6 +5679,7 @@ define('sequence/components/Divider',[
boxLayer: env.fullMaskLayer, boxLayer: env.fullMaskLayer,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
labelWidth = boxed.width; labelWidth = boxed.width;
} }
@ -6171,12 +6213,13 @@ define('sequence/Renderer',[
updateBounds(stagesHeight) { updateBounds(stagesHeight) {
const cx = (this.minX + this.maxX) / 2; const cx = (this.minX + this.maxX) / 2;
const titleY = ((this.title.height > 0) ? const titleSize = this.sizer.measure(this.title);
(-this.theme.titleMargin - this.title.height) : 0 const titleY = ((titleSize.height > 0) ?
(-this.theme.titleMargin - titleSize.height) : 0
); );
this.title.set({x: cx, y: titleY}); this.title.set({x: cx, y: titleY});
const halfTitleWidth = this.title.width / 2; const halfTitleWidth = titleSize.width / 2;
const margin = this.theme.outerMargin; const margin = this.theme.outerMargin;
const x0 = Math.min(this.minX, cx - halfTitleWidth) - margin; const x0 = Math.min(this.minX, cx - halfTitleWidth) - margin;
const x1 = Math.max(this.maxX, cx + halfTitleWidth) + margin; const x1 = Math.max(this.maxX, cx + halfTitleWidth) + margin;
@ -6186,15 +6229,15 @@ define('sequence/Renderer',[
this.width = x1 - x0; this.width = x1 - x0;
this.height = y1 - y0; this.height = y1 - y0;
this.fullMaskReveal.setAttribute('x', x0); const fullSize = {
this.fullMaskReveal.setAttribute('y', y0); 'x': x0,
this.fullMaskReveal.setAttribute('width', this.width); 'y': y0,
this.fullMaskReveal.setAttribute('height', this.height); 'width': this.width,
'height': this.height,
};
this.lineMaskReveal.setAttribute('x', x0); svg.setAttributes(this.fullMaskReveal, fullSize);
this.lineMaskReveal.setAttribute('y', y0); svg.setAttributes(this.lineMaskReveal, fullSize);
this.lineMaskReveal.setAttribute('width', this.width);
this.lineMaskReveal.setAttribute('height', this.height);
this.base.setAttribute('viewBox', ( this.base.setAttribute('viewBox', (
x0 + ' ' + y0 + ' ' + x0 + ' ' + y0 + ' ' +

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
defineDescribe('Readme', [ define([
'./SequenceDiagram', './SequenceDiagram',
'image/ImageRegion', 'image/ImageRegion',
'image/ImageSimilarity', 'image/ImageSimilarity',
@ -70,6 +70,8 @@ defineDescribe('Readme', [
return (fetch('README.md') return (fetch('README.md')
.then((response) => response.text()) .then((response) => response.text())
.then(findSamples) .then(findSamples)
.then((samples) => samples.forEach(makeSampleTests)) .then((samples) => describe('Readme', () => {
samples.forEach(makeSampleTests);
}))
); );
}); });

View File

@ -494,12 +494,13 @@ define([
updateBounds(stagesHeight) { updateBounds(stagesHeight) {
const cx = (this.minX + this.maxX) / 2; const cx = (this.minX + this.maxX) / 2;
const titleY = ((this.title.height > 0) ? const titleSize = this.sizer.measure(this.title);
(-this.theme.titleMargin - this.title.height) : 0 const titleY = ((titleSize.height > 0) ?
(-this.theme.titleMargin - titleSize.height) : 0
); );
this.title.set({x: cx, y: titleY}); this.title.set({x: cx, y: titleY});
const halfTitleWidth = this.title.width / 2; const halfTitleWidth = titleSize.width / 2;
const margin = this.theme.outerMargin; const margin = this.theme.outerMargin;
const x0 = Math.min(this.minX, cx - halfTitleWidth) - margin; const x0 = Math.min(this.minX, cx - halfTitleWidth) - margin;
const x1 = Math.max(this.maxX, cx + halfTitleWidth) + margin; const x1 = Math.max(this.maxX, cx + halfTitleWidth) + margin;
@ -509,15 +510,15 @@ define([
this.width = x1 - x0; this.width = x1 - x0;
this.height = y1 - y0; this.height = y1 - y0;
this.fullMaskReveal.setAttribute('x', x0); const fullSize = {
this.fullMaskReveal.setAttribute('y', y0); 'x': x0,
this.fullMaskReveal.setAttribute('width', this.width); 'y': y0,
this.fullMaskReveal.setAttribute('height', this.height); 'width': this.width,
'height': this.height,
};
this.lineMaskReveal.setAttribute('x', x0); svg.setAttributes(this.fullMaskReveal, fullSize);
this.lineMaskReveal.setAttribute('y', y0); svg.setAttributes(this.lineMaskReveal, fullSize);
this.lineMaskReveal.setAttribute('width', this.width);
this.lineMaskReveal.setAttribute('height', this.height);
this.base.setAttribute('viewBox', ( this.base.setAttribute('viewBox', (
x0 + ' ' + y0 + ' ' + x0 + ' ' + y0 + ' ' +

View File

@ -153,4 +153,35 @@ defineDescribe('SequenceDiagram', [
expect(content).toContain('<g class="region collapsed"'); expect(content).toContain('<g class="region collapsed"');
}); });
it('measures OS fonts correctly on the first render', (done) => {
const code = 'title message';
const sd = new SequenceDiagram(code);
const widthImmediate = sd.getSize().width;
expect(widthImmediate).toBeGreaterThan(40);
sd.set(code);
expect(sd.getSize().width).toEqual(widthImmediate);
setTimeout(() => {
sd.set(code);
expect(sd.getSize().width).toEqual(widthImmediate);
done();
}, 500);
});
it('measures embedded fonts correctly on the first render', (done) => {
const code = 'theme sketch\ntitle message';
const sd = new SequenceDiagram(code);
const widthImmediate = sd.getSize().width;
expect(widthImmediate).toBeGreaterThan(40);
sd.set(code);
expect(sd.getSize().width).toEqual(widthImmediate);
setTimeout(() => {
sd.set(code);
expect(sd.getSize().width).toEqual(widthImmediate);
done();
}, 500);
});
}); });

View File

@ -50,6 +50,7 @@ define([
boxLayer: clickable, boxLayer: clickable,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
clickable.insertBefore(svg.make('rect', { clickable.insertBefore(svg.make('rect', {
'x': x - text.width / 2, 'x': x - text.width / 2,

View File

@ -56,6 +56,7 @@ define([
boxLayer: blockInfo.hold, boxLayer: blockInfo.hold,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
const labelRender = SVGShapes.renderBoxedText(label, { const labelRender = SVGShapes.renderBoxedText(label, {
@ -67,6 +68,7 @@ define([
boxLayer: env.lineMaskLayer, boxLayer: env.lineMaskLayer,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
const labelHeight = Math.max( const labelHeight = Math.max(

View File

@ -227,6 +227,7 @@ define([
boxLayer: env.lineMaskLayer, boxLayer: env.lineMaskLayer,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
const labelW = (label ? ( const labelW = (label ? (
renderedText.width + renderedText.width +
@ -353,6 +354,7 @@ define([
boxLayer: env.lineMaskLayer, boxLayer: env.lineMaskLayer,
labelLayer, labelLayer,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
} }

View File

@ -63,6 +63,7 @@ define([
boxLayer: env.fullMaskLayer, boxLayer: env.fullMaskLayer,
labelLayer: clickable, labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}); });
labelWidth = boxed.width; labelWidth = boxed.width;
} }

View File

@ -42,15 +42,16 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
formatted: label, formatted: label,
y, y,
}); });
const size = env.textSizer.measure(labelNode);
const fullW = ( const fullW = (
labelNode.width + size.width +
config.padding.left + config.padding.left +
config.padding.right config.padding.right
); );
const fullH = ( const fullH = (
config.padding.top + config.padding.top +
labelNode.height + size.height +
config.padding.bottom config.padding.bottom
); );
if(x0 === null && xMid !== null) { if(x0 === null && xMid !== null) {

View File

@ -25,8 +25,6 @@ define(['svg/SVGUtilities'], (svg) => {
x: 0, x: 0,
y: 0, y: 0,
}; };
this.width = 0;
this.height = 0;
this.nodes = []; this.nodes = [];
this.set(initialState); this.set(initialState);
} }
@ -53,8 +51,6 @@ define(['svg/SVGUtilities'], (svg) => {
_reset() { _reset() {
this._rebuildNodes(0); this._rebuildNodes(0);
this.width = 0;
this.height = 0;
} }
_renderText() { _renderText() {
@ -66,13 +62,10 @@ define(['svg/SVGUtilities'], (svg) => {
const formatted = this.state.formatted; const formatted = this.state.formatted;
this._rebuildNodes(formatted.length); this._rebuildNodes(formatted.length);
let maxWidth = 0;
this.nodes.forEach(({text, element}, i) => { this.nodes.forEach(({text, element}, i) => {
const ln = formatted[i].reduce((v, pt) => v + pt.text, ''); const ln = formatted[i].reduce((v, pt) => v + pt.text, '');
text.nodeValue = ln; text.nodeValue = ln;
maxWidth = Math.max(maxWidth, ln.length);
}); });
this.width = maxWidth;
} }
_updateX() { _updateX() {
@ -85,7 +78,6 @@ define(['svg/SVGUtilities'], (svg) => {
this.nodes.forEach(({element}, i) => { this.nodes.forEach(({element}, i) => {
element.setAttribute('y', this.state.y + i); element.setAttribute('y', this.state.y + i);
}); });
this.height = this.nodes.length;
} }
firstLine() { firstLine() {
@ -123,6 +115,11 @@ define(['svg/SVGUtilities'], (svg) => {
class SizeTester { class SizeTester {
measure(attrs, formatted) { measure(attrs, formatted) {
if(attrs.state) {
formatted = attrs.state.formatted;
attrs = attrs.state.attrs;
}
if(!formatted || !formatted.length) { if(!formatted || !formatted.length) {
return {width: 0, height: 0}; return {width: 0, height: 0};
} }

View File

@ -74,6 +74,7 @@ define([
labelLayer, labelLayer,
boxRenderer = null, boxRenderer = null,
SVGTextBlockClass = SVGTextBlock, SVGTextBlockClass = SVGTextBlock,
textSizer,
}) { }) {
if(!formatted || !formatted.length) { if(!formatted || !formatted.length) {
return {width: 0, height: 0, label: null, box: null}; return {width: 0, height: 0, label: null, box: null};
@ -88,20 +89,21 @@ define([
y: y + padding.top, y: y + padding.top,
}); });
const width = (label.width + padding.left + padding.right); const size = textSizer.measure(label);
const height = (label.height + padding.top + padding.bottom); const width = (size.width + padding.left + padding.right);
const height = (size.height + padding.top + padding.bottom);
let box = null; let box = null;
if(boxRenderer) { if(boxRenderer) {
box = boxRenderer({ box = boxRenderer({
'x': anchorX - label.width * shift - padding.left, 'x': anchorX - size.width * shift - padding.left,
'y': y, 'y': y,
'width': width, 'width': width,
'height': height, 'height': height,
}); });
} else { } else {
box = renderBox(boxAttrs, { box = renderBox(boxAttrs, {
'x': anchorX - label.width * shift - padding.left, 'x': anchorX - size.width * shift - padding.left,
'y': y, 'y': y,
'width': width, 'width': width,
'height': height, 'height': height,

View File

@ -54,8 +54,17 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
}); });
describe('renderBoxedText', () => { describe('renderBoxedText', () => {
let o = null;
let sizer = null;
beforeEach(() => {
o = document.createElement('p');
sizer = {
measure: () => ({width: 64, height: 128}),
};
});
it('renders a label', () => { it('renders a label', () => {
const o = document.createElement('p');
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], { const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1, x: 1,
y: 2, y: 2,
@ -64,6 +73,7 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
labelAttrs: {'font-size': 10, 'line-height': 1.5, 'foo': 'bar'}, labelAttrs: {'font-size': 10, 'line-height': 1.5, 'foo': 'bar'},
boxLayer: o, boxLayer: o,
labelLayer: o, labelLayer: o,
textSizer: sizer,
}); });
expect(rendered.label.state.formatted).toEqual([[{text: 'foo'}]]); expect(rendered.label.state.formatted).toEqual([[{text: 'foo'}]]);
expect(rendered.label.state.x).toEqual(5); expect(rendered.label.state.x).toEqual(5);
@ -72,7 +82,6 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
}); });
it('positions a box beneath the rendered label', () => { it('positions a box beneath the rendered label', () => {
const o = document.createElement('p');
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], { const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1, x: 1,
y: 2, y: 2,
@ -81,16 +90,19 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
labelAttrs: {'font-size': 10, 'line-height': 1.5}, labelAttrs: {'font-size': 10, 'line-height': 1.5},
boxLayer: o, boxLayer: o,
labelLayer: o, labelLayer: o,
textSizer: sizer,
}); });
expect(rendered.box.getAttribute('x')).toEqual('1'); expect(rendered.box.getAttribute('x')).toEqual('1');
expect(rendered.box.getAttribute('y')).toEqual('2'); expect(rendered.box.getAttribute('y')).toEqual('2');
expect(rendered.box.getAttribute('height')).toEqual('55'); expect(rendered.box.getAttribute('width'))
.toEqual(String(4 + 16 + 64));
expect(rendered.box.getAttribute('height'))
.toEqual(String(8 + 32 + 128));
expect(rendered.box.getAttribute('foo')).toEqual('bar'); expect(rendered.box.getAttribute('foo')).toEqual('bar');
expect(rendered.box.parentNode).toEqual(o); expect(rendered.box.parentNode).toEqual(o);
}); });
it('returns the size of the rendered box', () => { it('returns the size of the rendered box', () => {
const o = document.createElement('p');
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], { const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1, x: 1,
y: 2, y: 2,
@ -99,9 +111,10 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
labelAttrs: {'font-size': 10, 'line-height': 1.5}, labelAttrs: {'font-size': 10, 'line-height': 1.5},
boxLayer: o, boxLayer: o,
labelLayer: o, labelLayer: o,
textSizer: sizer,
}); });
expect(rendered.width).toBeGreaterThan(20 - 1); expect(rendered.width).toEqual(4 + 16 + 64);
expect(rendered.height).toEqual(55); expect(rendered.height).toEqual(8 + 32 + 128);
}); });
}); });
}); });

View File

@ -38,6 +38,27 @@ define(['./SVGUtilities'], (svg) => {
}); });
} }
function measureLine(tester, line) {
if(!line.length) {
return 0;
}
const labelKey = JSON.stringify(line);
const knownWidth = tester.widths.get(labelKey);
if(knownWidth !== undefined) {
return knownWidth;
}
// getComputedTextLength forces a reflow, so only call it if nothing
// else can tell us the length
svg.empty(tester.node);
populateSvgTextLine(tester.node, line);
const width = tester.node.getComputedTextLength();
tester.widths.set(labelKey, width);
return width;
}
const EMPTY = []; const EMPTY = [];
class SVGTextBlock { class SVGTextBlock {
@ -49,8 +70,6 @@ define(['./SVGUtilities'], (svg) => {
x: 0, x: 0,
y: 0, y: 0,
}; };
this.width = 0;
this.height = 0;
this.lines = []; this.lines = [];
this.set(initialState); this.set(initialState);
} }
@ -76,8 +95,6 @@ define(['./SVGUtilities'], (svg) => {
_reset() { _reset() {
this._rebuildLines(0); this._rebuildLines(0);
this.width = 0;
this.height = 0;
} }
_renderText() { _renderText() {
@ -93,7 +110,6 @@ define(['./SVGUtilities'], (svg) => {
this._rebuildLines(formatted.length); this._rebuildLines(formatted.length);
let maxWidth = 0;
this.lines.forEach((ln, i) => { this.lines.forEach((ln, i) => {
const id = JSON.stringify(formatted[i]); const id = JSON.stringify(formatted[i]);
if(id !== ln.latest) { if(id !== ln.latest) {
@ -101,9 +117,7 @@ define(['./SVGUtilities'], (svg) => {
populateSvgTextLine(ln.node, formatted[i]); populateSvgTextLine(ln.node, formatted[i]);
ln.latest = id; ln.latest = id;
} }
maxWidth = Math.max(maxWidth, ln.node.getComputedTextLength());
}); });
this.width = maxWidth;
} }
_updateX() { _updateX() {
@ -117,7 +131,6 @@ define(['./SVGUtilities'], (svg) => {
this.lines.forEach(({node}, i) => { this.lines.forEach(({node}, i) => {
node.setAttribute('y', this.state.y + i * lineHeight + size); node.setAttribute('y', this.state.y + i * lineHeight + size);
}); });
this.height = lineHeight * this.lines.length;
} }
firstLine() { firstLine() {
@ -164,47 +177,62 @@ define(['./SVGUtilities'], (svg) => {
this.cache = new Map(); this.cache = new Map();
} }
measure(attrs, formatted) { _measureHeight({attrs, formatted}) {
if(!formatted || !formatted.length) { return formatted.length * fontDetails(attrs).lineHeight;
return {width: 0, height: 0};
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
} }
let tester = this.cache.get(attrs); _measureWidth({attrs, formatted}) {
if(!formatted.length) {
return 0;
}
const attrKey = JSON.stringify(attrs);
let tester = this.cache.get(attrKey);
if(!tester) { if(!tester) {
tester = svg.make('text', attrs); const node = svg.make('text', attrs);
this.testers.appendChild(tester); this.testers.appendChild(node);
this.cache.set(attrs, tester); tester = {
node,
widths: new Map(),
};
this.cache.set(attrKey, tester);
} }
if(!this.testers.parentNode) { if(!this.testers.parentNode) {
this.container.appendChild(this.testers); this.container.appendChild(this.testers);
} }
let width = 0; return (formatted
formatted.forEach((line) => { .map((line) => measureLine(tester, line))
svg.empty(tester); .reduce((a, b) => Math.max(a, b), 0)
populateSvgTextLine(tester, line); );
width = Math.max(width, tester.getComputedTextLength()); }
});
_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};
}
measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return { return {
width, width: this._measureWidth(opts),
height: formatted.length * fontDetails(attrs).lineHeight, height: this._measureHeight(opts),
}; };
} }
measureHeight(attrs, formatted) { measureHeight(attrs, formatted) {
if(!formatted) { const opts = this._getMeasurementOpts(attrs, formatted);
return 0; return this._measureHeight(opts);
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
}
return formatted.length * fontDetails(attrs).lineHeight;
} }
resetCache() { resetCache() {

View File

@ -68,12 +68,6 @@ defineDescribe('SVGTextBlock', [
expect(hold.children[1].innerHTML).toEqual('bar'); expect(hold.children[1].innerHTML).toEqual('bar');
}); });
it('populates width and height with the size of the text', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
expect(block.width).toBeGreaterThan(0);
expect(block.height).toEqual(30);
});
it('re-uses text nodes when possible, adding more if needed', () => { it('re-uses text nodes when possible, adding more if needed', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]}); block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
const line0 = hold.children[0]; const line0 = hold.children[0];
@ -129,8 +123,7 @@ defineDescribe('SVGTextBlock', [
block.set({formatted: []}); block.set({formatted: []});
expect(hold.children.length).toEqual(0); expect(hold.children.length).toEqual(0);
expect(block.state.formatted).toEqual([]); expect(block.state.formatted).toEqual([]);
expect(block.width).toEqual(0); expect(block.lines.length).toEqual(0);
expect(block.height).toEqual(0);
}); });
}); });
@ -148,6 +141,13 @@ defineDescribe('SVGTextBlock', [
expect(size.height).toEqual(15); expect(size.height).toEqual(15);
}); });
it('calculates the size of text blocks', () => {
block.set({formatted: [[{text: 'foo'}]]});
const size = tester.measure(block);
expect(size.width).toBeGreaterThan(0);
expect(size.height).toEqual(15);
});
it('measures multiline text', () => { it('measures multiline text', () => {
const size = tester.measure(attrs, [ const size = tester.measure(attrs, [
[{text: 'foo'}], [{text: 'foo'}],

View File

@ -7,13 +7,17 @@ define(() => {
return document.createTextNode(text); return document.createTextNode(text);
} }
function make(type, attrs = {}, children = []) { function setAttributes(target, attrs) {
const o = document.createElementNS(NS, type);
for(const k in attrs) { for(const k in attrs) {
if(attrs.hasOwnProperty(k)) { if(attrs.hasOwnProperty(k)) {
o.setAttribute(k, attrs[k]); target.setAttribute(k, attrs[k]);
} }
} }
}
function make(type, attrs = {}, children = []) {
const o = document.createElementNS(NS, type);
setAttributes(o, attrs);
for(const c of children) { for(const c of children) {
o.appendChild(c); o.appendChild(c);
} }
@ -37,6 +41,7 @@ define(() => {
makeText, makeText,
make, make,
makeContainer, makeContainer,
setAttributes,
empty, empty,
}; };
}); });