Significantly reduce reflows for faster page loads

This commit is contained in:
David Evans 2018-02-17 16:15:36 +00:00
parent a0de2914a3
commit 3d89dc3548
18 changed files with 846 additions and 265 deletions

View File

@ -3539,27 +3539,6 @@ 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 {
@ -3676,35 +3655,68 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
}); });
this.container = container; this.container = container;
this.cache = new Map(); 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}) { _measureHeight({attrs, formatted}) {
return formatted.length * fontDetails(attrs).lineHeight; return formatted.length * fontDetails(attrs).lineHeight;
} }
_measureWidth({attrs, formatted}) { _measureLine(attrCache, line) {
if(!formatted.length) { if(!line.length) {
return 0; return 0;
} }
const attrKey = JSON.stringify(attrs); const labelKey = JSON.stringify(line);
let tester = this.cache.get(attrKey); const cache = attrCache.lines.get(labelKey);
if(!tester) { if(cache.width === null) {
const node = svg.make('text', attrs); window.console.warn('Performing unexpected measurement', line);
this.testers.appendChild(node); this.performMeasurements();
tester = { }
node, return cache.width;
widths: new Map(),
};
this.cache.set(attrKey, tester);
} }
if(!this.testers.parentNode) { _measureWidth(opts) {
this.container.appendChild(this.testers); if(!opts.formatted.length) {
return 0;
} }
return (formatted const attrCache = this._expectMeasure(opts);
.map((line) => measureLine(tester, line))
return (opts.formatted
.map((line) => this._measureLine(attrCache, line))
.reduce((a, b) => Math.max(a, b), 0) .reduce((a, b) => Math.max(a, b), 0)
); );
} }
@ -3723,6 +3735,52 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
return {attrs, 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) { measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted); const opts = this._getMeasurementOpts(attrs, formatted);
return { return {
@ -3737,15 +3795,8 @@ define('svg/SVGTextBlock',['./SVGUtilities'], (svg) => {
} }
resetCache() { resetCache() {
svg.empty(this.testers);
this.cache.clear(); this.cache.clear();
} }
detach() {
if(this.testers.parentNode) {
this.container.removeChild(this.testers);
}
}
} }
SVGTextBlock.SizeTester = SizeTester; SVGTextBlock.SizeTester = SizeTester;
@ -4010,6 +4061,16 @@ define('sequence/components/BaseComponent',[],() => {
this.makeState(state); this.makeState(state);
} }
prepareMeasurements(/*stage, {
renderer,
theme,
agentInfos,
textSizer,
state,
components,
}*/) {
}
separationPre(/*stage, { separationPre(/*stage, {
renderer, renderer,
theme, theme,
@ -4128,6 +4189,13 @@ define('sequence/components/Block',[
'use strict'; 'use strict';
class BlockSplit extends BaseComponent { class BlockSplit extends BaseComponent {
prepareMeasurements({left, tag, label}, env) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.type).section;
env.textSizer.expectMeasure(config.tag.labelAttrs, tag);
env.textSizer.expectMeasure(config.label.labelAttrs, label);
}
separation({left, right, tag, label}, env) { separation({left, right, tag, label}, env) {
const blockInfo = env.state.blocks.get(left); const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.type).section; const config = env.theme.getBlock(blockInfo.type).section;
@ -4242,8 +4310,14 @@ define('sequence/components/Block',[
return blockInfo; return blockInfo;
} }
prepareMeasurements(stage, env) {
this.storeBlockInfo(stage, env);
super.prepareMeasurements(stage, env);
}
separationPre(stage, env) { separationPre(stage, env) {
this.storeBlockInfo(stage, env); this.storeBlockInfo(stage, env);
super.separationPre(stage, env);
} }
separation(stage, env) { separation(stage, env) {
@ -4379,16 +4453,23 @@ define('sequence/components/Parallel',[
} }
class Parallel extends BaseComponent { class Parallel extends BaseComponent {
separationPre(stage, env) { invokeChildren(stage, env, methodName) {
stage.stages.forEach((subStage) => { return stage.stages.map((subStage) => {
env.components.get(subStage.type).separationPre(subStage, env); const component = env.components.get(subStage.type);
return component[methodName](subStage, env);
}); });
} }
prepareMeasurements(stage, env) {
this.invokeChildren(stage, env, 'prepareMeasurements');
}
separationPre(stage, env) {
this.invokeChildren(stage, env, 'separationPre');
}
separation(stage, env) { separation(stage, env) {
stage.stages.forEach((subStage) => { this.invokeChildren(stage, env, 'separation');
env.components.get(subStage.type).separation(subStage, env);
});
} }
renderPre(stage, env) { renderPre(stage, env) {
@ -4398,11 +4479,9 @@ define('sequence/components/Parallel',[
asynchronousY: null, asynchronousY: null,
}; };
return stage.stages.map((subStage) => { return this.invokeChildren(stage, env, 'renderPre')
const component = env.components.get(subStage.type); .map((r) => BaseComponent.cleanRenderPreResult(r))
const subResult = component.renderPre(subStage, env); .reduce(mergeResults, baseResults);
return BaseComponent.cleanRenderPreResult(subResult);
}).reduce(mergeResults, baseResults);
} }
render(stage, env) { render(stage, env) {
@ -4424,24 +4503,19 @@ define('sequence/components/Parallel',[
} }
renderHidden(stage, env) { renderHidden(stage, env) {
stage.stages.forEach((subStage) => { this.invokeChildren(stage, env, 'renderHidden');
const component = env.components.get(subStage.type);
component.renderHidden(subStage, env);
});
} }
shouldHide(stage, env) { shouldHide(stage, env) {
const result = { const baseResults = {
self: false, self: false,
nest: 0, nest: 0,
}; };
stage.stages.forEach((subStage) => { return this.invokeChildren(stage, env, 'shouldHide')
const component = env.components.get(subStage.type); .reduce((result, {self = false, nest = 0} = {}) => ({
const hide = component.shouldHide(subStage, env) || {}; self: result.self || Boolean(self),
result.self = (result.self || Boolean(hide.self)); nest: result.nest + nest,
result.nest += (hide.nest || 0); }), baseResults);
});
return result;
} }
} }
@ -4514,6 +4588,11 @@ define('sequence/components/AgentCap',[
return config || env.theme.agentCap.box; return config || env.theme.agentCap.box;
} }
prepareMeasurements({formattedLabel, options}, env) {
const config = this.getConfig(options, env);
env.textSizer.expectMeasure(config.labelAttrs, formattedLabel);
}
separation({formattedLabel, options}, env) { separation({formattedLabel, options}, env) {
const config = this.getConfig(options, env); const config = this.getConfig(options, env);
const width = ( const width = (
@ -4572,6 +4651,9 @@ define('sequence/components/AgentCap',[
} }
class CapCross { class CapCross {
prepareMeasurements() {
}
separation(agentInfo, env) { separation(agentInfo, env) {
const config = env.theme.agentCap.cross; const config = env.theme.agentCap.cross;
return { return {
@ -4616,6 +4698,11 @@ define('sequence/components/AgentCap',[
} }
class CapBar { class CapBar {
prepareMeasurements({formattedLabel}, env) {
const config = env.theme.agentCap.box;
env.textSizer.expectMeasure(config.labelAttrs, formattedLabel);
}
separation({formattedLabel}, env) { separation({formattedLabel}, env) {
const config = env.theme.agentCap.box; const config = env.theme.agentCap.box;
const width = ( const width = (
@ -4672,6 +4759,9 @@ define('sequence/components/AgentCap',[
} }
class CapFade { class CapFade {
prepareMeasurements() {
}
separation({currentRad}) { separation({currentRad}) {
return { return {
left: currentRad, left: currentRad,
@ -4733,6 +4823,9 @@ define('sequence/components/AgentCap',[
} }
class CapNone { class CapNone {
prepareMeasurements() {
}
separation({currentRad}) { separation({currentRad}) {
return { return {
left: currentRad, left: currentRad,
@ -4781,6 +4874,14 @@ define('sequence/components/AgentCap',[
this.begin = begin; this.begin = begin;
} }
prepareMeasurements({mode, agentIDs}, env) {
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
const cap = AGENT_CAPS[mode];
cap.prepareMeasurements(agentInfo, env, this.begin);
});
}
separationPre({mode, agentIDs}, env) { separationPre({mode, agentIDs}, env) {
agentIDs.forEach((id) => { agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id); const agentInfo = env.agentInfos.get(id);
@ -5011,6 +5112,15 @@ define('sequence/components/Connect',[
]; ];
class Connect extends BaseComponent { class Connect extends BaseComponent {
prepareMeasurements({agentIDs, label}, env) {
const config = env.theme.connect;
const loopback = (agentIDs[0] === agentIDs[1]);
const labelAttrs = (loopback ?
config.label.loopbackAttrs : config.label.attrs);
env.textSizer.expectMeasure(labelAttrs, label);
}
separationPre({agentIDs}, env) { separationPre({agentIDs}, env) {
const r = env.theme.connect.source.radius; const r = env.theme.connect.source.radius;
agentIDs.forEach((id) => { agentIDs.forEach((id) => {
@ -5029,15 +5139,17 @@ define('sequence/components/Connect',[
const lArrow = ARROWHEADS[options.left]; const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right]; const rArrow = ARROWHEADS[options.right];
let labelWidth = ( const loopback = (agentIDs[0] === agentIDs[1]);
env.textSizer.measure(config.label.attrs, label).width const labelAttrs = (loopback ?
); config.label.loopbackAttrs : config.label.attrs);
let labelWidth = env.textSizer.measure(labelAttrs, label).width;
if(labelWidth > 0) { if(labelWidth > 0) {
labelWidth += config.label.padding * 2; labelWidth += config.label.padding * 2;
} }
const info1 = env.agentInfos.get(agentIDs[0]); const info1 = env.agentInfos.get(agentIDs[0]);
if(agentIDs[0] === agentIDs[1]) { if(loopback) {
env.addSpacing(agentIDs[0], { env.addSpacing(agentIDs[0], {
left: 0, left: 0,
right: ( right: (
@ -5391,6 +5503,8 @@ define('sequence/components/Connect',[
} }
class ConnectDelayEnd extends Connect { class ConnectDelayEnd extends Connect {
prepareMeasurements() {}
separationPre() {} separationPre() {}
separation() {} separation() {}
@ -5453,6 +5567,11 @@ define('sequence/components/Note',['./BaseComponent', 'svg/SVGUtilities'], (Base
} }
class NoteComponent extends BaseComponent { class NoteComponent extends BaseComponent {
prepareMeasurements({mode, label}, env) {
const config = env.theme.getNote(mode);
env.textSizer.expectMeasure(config.labelAttrs, label);
}
renderPre({agentIDs}) { renderPre({agentIDs}) {
return {agentIDs}; return {agentIDs};
} }
@ -5713,6 +5832,11 @@ define('sequence/components/Divider',[
'use strict'; 'use strict';
class Divider extends BaseComponent { class Divider extends BaseComponent {
prepareMeasurements({mode, formattedLabel}, env) {
const config = env.theme.getDivider(mode);
env.textSizer.expectMeasure(config.labelAttrs, formattedLabel);
}
separation({mode, formattedLabel}, env) { separation({mode, formattedLabel}, env) {
const config = env.theme.getDivider(mode); const config = env.theme.getDivider(mode);
@ -5905,6 +6029,8 @@ define('sequence/Renderer',[
_bindMethods() { _bindMethods() {
this.separationStage = this.separationStage.bind(this); this.separationStage = this.separationStage.bind(this);
this.prepareMeasurementsStage =
this.prepareMeasurementsStage.bind(this);
this.renderStage = this.renderStage.bind(this); this.renderStage = this.renderStage.bind(this);
this.addThemeDef = this.addThemeDef.bind(this); this.addThemeDef = this.addThemeDef.bind(this);
this.addDef = this.addDef.bind(this); this.addDef = this.addDef.bind(this);
@ -6100,6 +6226,24 @@ define('sequence/Renderer',[
}); });
} }
prepareMeasurementsStage(stage) {
const env = {
renderer: this,
theme: this.theme,
agentInfos: this.agentInfos,
textSizer: this.sizer,
state: this.state,
components: this.components,
};
const component = this.components.get(stage.type);
if(!component) {
throw new Error('Unknown component: ' + stage.type);
}
component.prepareMeasurements(stage, env);
}
checkAgentRange(agentIDs, topY = 0) { checkAgentRange(agentIDs, topY = 0) {
if(agentIDs.length === 0) { if(agentIDs.length === 0) {
return topY; return topY;
@ -6273,7 +6417,7 @@ define('sequence/Renderer',[
}); });
} }
buildAgentInfos(agents, stages) { buildAgentInfos(agents) {
this.agentInfos = new Map(); this.agentInfos = new Map();
agents.forEach((agent, index) => { agents.forEach((agent, index) => {
this.agentInfos.set(agent.id, { this.agentInfos.set(agent.id, {
@ -6293,11 +6437,6 @@ define('sequence/Renderer',[
separations: new Map(), separations: new Map(),
}); });
}); });
this.visibleAgentIDs = ['[', ']'];
stages.forEach(this.separationStage);
this.positionAgents();
} }
updateBounds(stagesHeight) { updateBounds(stagesHeight) {
@ -6429,16 +6568,18 @@ define('sequence/Renderer',[
return this._setCollapsed(line, collapsed); return this._setCollapsed(line, collapsed);
} }
render(sequence) { _switchTheme(name) {
const prevHighlight = this.currentHighlight;
const oldTheme = this.theme; const oldTheme = this.theme;
this.theme = this.getThemeNamed(name);
this.theme.reset();
this.theme = this.getThemeNamed(sequence.meta.theme); return (this.theme !== oldTheme);
}
const themeChanged = (this.theme !== oldTheme); optimisedRenderPreReflow(sequence) {
const themeChanged = this._switchTheme(sequence.meta.theme);
this._reset(themeChanged); this._reset(themeChanged);
this.theme.reset();
this.metaCode.nodeValue = sequence.meta.code; this.metaCode.nodeValue = sequence.meta.code;
this.theme.addDefs(this.addThemeDef); this.theme.addDefs(this.addThemeDef);
@ -6446,21 +6587,47 @@ define('sequence/Renderer',[
attrs: this.theme.titleAttrs, attrs: this.theme.titleAttrs,
formatted: sequence.meta.title, formatted: sequence.meta.title,
}); });
this.sizer.expectMeasure(this.title);
this.minX = 0; this.minX = 0;
this.maxX = 0; this.maxX = 0;
this.buildAgentInfos(sequence.agents, sequence.stages);
this.buildAgentInfos(sequence.agents);
sequence.stages.forEach(this.prepareMeasurementsStage);
this._resetState(); this._resetState();
this.sizer.performMeasurementsPre();
}
optimisedRenderReflow() {
this.sizer.performMeasurementsAct();
}
optimisedRenderPostReflow(sequence) {
this.visibleAgentIDs = ['[', ']'];
sequence.stages.forEach(this.separationStage);
this._resetState();
this.positionAgents();
sequence.stages.forEach(this.renderStage); sequence.stages.forEach(this.renderStage);
const bottomY = this.checkAgentRange(['[', ']'], this.currentY); const bottomY = this.checkAgentRange(['[', ']'], this.currentY);
const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0); const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
this.updateBounds(stagesHeight); this.updateBounds(stagesHeight);
this.sizer.resetCache(); const prevHighlight = this.currentHighlight;
this.sizer.detach(); this.currentHighlight = -1;
this.setHighlight(prevHighlight); this.setHighlight(prevHighlight);
this.sizer.performMeasurementsPost();
this.sizer.resetCache();
}
render(sequence) {
this.optimisedRenderPreReflow(sequence);
this.optimisedRenderReflow();
this.optimisedRenderPostReflow(sequence);
} }
getThemeNames() { getThemeNames() {
@ -9434,8 +9601,8 @@ define('sequence/themes/Sketch',[
// but this fails when exporting as SVG / PNG (svg tags must // but this fails when exporting as SVG / PNG (svg tags must
// have no external dependencies). // have no external dependencies).
// const url = 'https://fonts.googleapis.com/css?family=' + FONT; // const url = 'https://fonts.googleapis.com/css?family=' + FONT;
// style.innerText = '@import url("' + url + '")'; // style.textContent = '@import url("' + url + '")';
style.innerText = ( style.textContent = (
'@font-face{' + '@font-face{' +
'font-family:"' + Handlee.name + '";' + 'font-family:"' + Handlee.name + '";' +
'src:url("data:font/woff2;base64,' + Handlee.woff2 + '");' + 'src:url("data:font/woff2;base64,' + Handlee.woff2 + '");' +
@ -9963,6 +10130,29 @@ define('sequence/SequenceDiagram',[
return meta.textContent; return meta.textContent;
} }
function renderAll(diagrams) {
const errors = [];
function storeError(sd, e) {
errors.push(e);
}
diagrams.forEach((diagram) => {
diagram.addEventListener('error', storeError);
diagram.optimisedRenderPreReflow();
});
diagrams.forEach((diagram) => {
diagram.optimisedRenderReflow();
});
diagrams.forEach((diagram) => {
diagram.optimisedRenderPostReflow();
diagram.removeEventListener('error', storeError);
});
if(errors.length > 0) {
throw errors;
}
}
class SequenceDiagram extends EventObject { class SequenceDiagram extends EventObject {
constructor(code = null, options = {}) { constructor(code = null, options = {}) {
super(); super();
@ -9988,7 +10178,7 @@ define('sequence/SequenceDiagram',[
if(options.interactive) { if(options.interactive) {
this.addInteractivity(); this.addInteractivity();
} }
if(typeof this.code === 'string') { if(typeof this.code === 'string' && options.render !== false) {
this.render(); this.render();
} }
} }
@ -10005,14 +10195,16 @@ define('sequence/SequenceDiagram',[
}, options)); }, options));
} }
set(code = '') { set(code = '', {render = true} = {}) {
if(this.code === code) { if(this.code === code) {
return; return;
} }
this.code = code; this.code = code;
if(render) {
this.render(); this.render();
} }
}
process(code) { process(code) {
const parsed = this.parser.parse(code); const parsed = this.parser.parse(code);
@ -10109,29 +10301,92 @@ define('sequence/SequenceDiagram',[
}; };
} }
render(processed = null) { _revertParent(state) {
const dom = this.renderer.svg(); const dom = this.renderer.svg();
const originalParent = dom.parentNode; if(dom.parentNode !== state.originalParent) {
document.body.removeChild(dom);
if(state.originalParent) {
state.originalParent.appendChild(dom);
}
}
}
_sendRenderError(e) {
this._revertParent(this.renderState);
this.renderState.error = true;
this.trigger('error', [this, e]);
}
optimisedRenderPreReflow(processed = null) {
const dom = this.renderer.svg();
this.renderState = {
originalParent: dom.parentNode,
processed,
error: false,
};
const state = this.renderState;
if(!document.body.contains(dom)) { if(!document.body.contains(dom)) {
if(originalParent) { if(state.originalParent) {
originalParent.removeChild(dom); state.originalParent.removeChild(dom);
} }
document.body.appendChild(dom); document.body.appendChild(dom);
} }
try { try {
if(!processed) { if(!state.processed) {
processed = this.process(this.code); state.processed = this.process(this.code);
} }
this.renderer.render(processed); this.renderer.optimisedRenderPreReflow(state.processed);
this.latestProcessed = processed; } catch(e) {
this._sendRenderError(e);
}
}
optimisedRenderReflow() {
try {
if(!this.renderState.error) {
this.renderer.optimisedRenderReflow();
}
} catch(e) {
this._sendRenderError(e);
}
}
optimisedRenderPostReflow() {
const state = this.renderState;
try {
if(!state.error) {
this.renderer.optimisedRenderPostReflow(state.processed);
}
} catch(e) {
this._sendRenderError(e);
}
this.renderState = null;
if(!state.error) {
this._revertParent(state);
this.latestProcessed = state.processed;
this.trigger('render', [this]); this.trigger('render', [this]);
} finally {
if(dom.parentNode !== originalParent) {
document.body.removeChild(dom);
if(originalParent) {
originalParent.appendChild(dom);
} }
} }
render(processed = null) {
let latestError = null;
function storeError(sd, e) {
latestError = e;
}
this.addEventListener('error', storeError);
this.optimisedRenderPreReflow(processed);
this.optimisedRenderReflow();
this.optimisedRenderPostReflow();
this.removeEventListener('error', storeError);
if(latestError) {
throw latestError;
} }
} }
@ -10160,6 +10415,10 @@ define('sequence/SequenceDiagram',[
return extractCodeFromSVG(svg); return extractCodeFromSVG(svg);
} }
renderAll(diagrams) {
return renderAll(diagrams);
}
dom() { dom() {
return this.renderer.svg(); return this.renderer.svg();
} }
@ -10176,16 +10435,13 @@ define('sequence/SequenceDiagram',[
}; };
} }
function convert(element, code = null, options = {}) { function convertOne(element, code = null, options = {}) {
if(element.tagName === 'svg') { if(element.tagName === 'svg') {
return null; return null;
} }
if(code === null) { if(code === null) {
code = element.innerText; code = element.textContent;
} else if(typeof code === 'object') {
options = code;
code = options.code;
} }
const tagOptions = parseTagOptions(element); const tagOptions = parseTagOptions(element);
@ -10207,6 +10463,24 @@ define('sequence/SequenceDiagram',[
return diagram; return diagram;
} }
function convert(elements, code = null, options = {}) {
if(code && typeof code === 'object') {
options = code;
code = options.code;
}
if(Array.isArray(elements)) {
const opts = Object.assign({}, options, {render: false});
const diagrams = elements.map((el) => convertOne(el, code, opts));
if(options.render !== false) {
renderAll(diagrams);
}
return diagrams;
} else {
return convertOne(elements, code, options);
}
}
function convertAll(root = null, className = 'sequence-diagram') { function convertAll(root = null, className = 'sequence-diagram') {
if(typeof root === 'string') { if(typeof root === 'string') {
className = root; className = root;
@ -10218,13 +10492,15 @@ define('sequence/SequenceDiagram',[
} else { } else {
elements = (root || document).getElementsByClassName(className); elements = (root || document).getElementsByClassName(className);
} }
// Convert from "live" collection to static to avoid infinite loops: // Convert from "live" collection to static to avoid infinite loops:
const els = []; const els = [];
for(let i = 0; i < elements.length; ++ i) { for(let i = 0; i < elements.length; ++ i) {
els.push(elements[i]); els.push(elements[i]);
} }
// Convert elements // Convert elements
els.forEach((el) => convert(el)); convert(els);
} }
return Object.assign(SequenceDiagram, { return Object.assign(SequenceDiagram, {
@ -10237,6 +10513,7 @@ define('sequence/SequenceDiagram',[
addTheme, addTheme,
registerCodeMirrorMode, registerCodeMirrorMode,
extractCodeFromSVG, extractCodeFromSVG,
renderAll,
convert, convert,
convertAll, convertAll,
}); });

File diff suppressed because one or more lines are too long

View File

@ -61,24 +61,30 @@
<script>document.addEventListener('DOMContentLoaded', () => { <script>document.addEventListener('DOMContentLoaded', () => {
const diagrams = [];
// Example 1: // Example 1:
(() => { (() => {
const diagram = new SequenceDiagram(); const diagram = new SequenceDiagram();
diagram.set('A -> B\nB -> A'); diagram.set('A -> B\nB -> A', {render: false});
diagram.dom().setAttribute('class', 'sequence-diagram'); diagram.dom().setAttribute('class', 'sequence-diagram');
document.getElementById('hold1').appendChild(diagram.dom()); document.getElementById('hold1').appendChild(diagram.dom());
diagram.setHighlight(1); diagram.setHighlight(1);
diagrams.push(diagram);
})(); })();
// Snippets: // Snippets:
const elements = document.getElementsByClassName('example'); const elements = document.getElementsByClassName('example');
for(let i = 0; i < elements.length; ++ i) { for(let i = 0; i < elements.length; ++ i) {
const el = elements[i]; const el = elements[i];
const diagram = new SequenceDiagram(el.innerText); const diagram = new SequenceDiagram(el.textContent, {render: false});
diagram.dom().setAttribute('class', 'example-diagram'); diagram.dom().setAttribute('class', 'example-diagram');
el.parentNode.insertBefore(diagram.dom(), el); el.parentNode.insertBefore(diagram.dom(), el);
diagrams.push(diagram);
} }
SequenceDiagram.renderAll(diagrams);
CodeMirror.colorize(); CodeMirror.colorize();
}, {once: true});</script> }, {once: true});</script>

View File

@ -40,7 +40,7 @@
const links = []; const links = [];
for(let i = 0; i < linkElements.length; ++ i) { for(let i = 0; i < linkElements.length; ++ i) {
links.push({ links.push({
label: linkElements[i].innerText, label: linkElements[i].textContent,
href: linkElements[i].getAttribute('href'), href: linkElements[i].getAttribute('href'),
}); });
} }

View File

@ -289,7 +289,7 @@ define(['require'], (require) => {
} }
buildLibrary(container) { buildLibrary(container) {
this.library.forEach((lib) => { const diagrams = this.library.map((lib) => {
const holdInner = makeNode('div', { const holdInner = makeNode('div', {
'title': lib.title || lib.code, 'title': lib.title || lib.code,
}); });
@ -301,17 +301,22 @@ define(['require'], (require) => {
this.addCodeBlock.bind(this, lib.code) this.addCodeBlock.bind(this, lib.code)
); );
container.appendChild(hold); container.appendChild(hold);
try { const diagram = this.diagram.clone({
this.diagram.clone({
code: simplifyPreview(lib.preview || lib.code), code: simplifyPreview(lib.preview || lib.code),
container: holdInner, container: holdInner,
render: false,
}); });
} catch(e) { diagram.addEventListener('error', (sd, e) => {
window.console.log('Failed to render preview', e); window.console.warn('Failed to render preview', e);
hold.setAttribute('class', 'library-item broken'); hold.setAttribute('class', 'library-item broken');
holdInner.appendChild(makeText(lib.code)); holdInner.textContent = lib.code;
}
}); });
return diagram;
});
try {
this.diagram.renderAll(diagrams);
} catch(e) {}
} }
buildErrorReport() { buildErrorReport() {

View File

@ -99,6 +99,8 @@ define([
_bindMethods() { _bindMethods() {
this.separationStage = this.separationStage.bind(this); this.separationStage = this.separationStage.bind(this);
this.prepareMeasurementsStage =
this.prepareMeasurementsStage.bind(this);
this.renderStage = this.renderStage.bind(this); this.renderStage = this.renderStage.bind(this);
this.addThemeDef = this.addThemeDef.bind(this); this.addThemeDef = this.addThemeDef.bind(this);
this.addDef = this.addDef.bind(this); this.addDef = this.addDef.bind(this);
@ -294,6 +296,24 @@ define([
}); });
} }
prepareMeasurementsStage(stage) {
const env = {
renderer: this,
theme: this.theme,
agentInfos: this.agentInfos,
textSizer: this.sizer,
state: this.state,
components: this.components,
};
const component = this.components.get(stage.type);
if(!component) {
throw new Error('Unknown component: ' + stage.type);
}
component.prepareMeasurements(stage, env);
}
checkAgentRange(agentIDs, topY = 0) { checkAgentRange(agentIDs, topY = 0) {
if(agentIDs.length === 0) { if(agentIDs.length === 0) {
return topY; return topY;
@ -467,7 +487,7 @@ define([
}); });
} }
buildAgentInfos(agents, stages) { buildAgentInfos(agents) {
this.agentInfos = new Map(); this.agentInfos = new Map();
agents.forEach((agent, index) => { agents.forEach((agent, index) => {
this.agentInfos.set(agent.id, { this.agentInfos.set(agent.id, {
@ -487,11 +507,6 @@ define([
separations: new Map(), separations: new Map(),
}); });
}); });
this.visibleAgentIDs = ['[', ']'];
stages.forEach(this.separationStage);
this.positionAgents();
} }
updateBounds(stagesHeight) { updateBounds(stagesHeight) {
@ -623,16 +638,18 @@ define([
return this._setCollapsed(line, collapsed); return this._setCollapsed(line, collapsed);
} }
render(sequence) { _switchTheme(name) {
const prevHighlight = this.currentHighlight;
const oldTheme = this.theme; const oldTheme = this.theme;
this.theme = this.getThemeNamed(name);
this.theme.reset();
this.theme = this.getThemeNamed(sequence.meta.theme); return (this.theme !== oldTheme);
}
const themeChanged = (this.theme !== oldTheme); optimisedRenderPreReflow(sequence) {
const themeChanged = this._switchTheme(sequence.meta.theme);
this._reset(themeChanged); this._reset(themeChanged);
this.theme.reset();
this.metaCode.nodeValue = sequence.meta.code; this.metaCode.nodeValue = sequence.meta.code;
this.theme.addDefs(this.addThemeDef); this.theme.addDefs(this.addThemeDef);
@ -640,21 +657,47 @@ define([
attrs: this.theme.titleAttrs, attrs: this.theme.titleAttrs,
formatted: sequence.meta.title, formatted: sequence.meta.title,
}); });
this.sizer.expectMeasure(this.title);
this.minX = 0; this.minX = 0;
this.maxX = 0; this.maxX = 0;
this.buildAgentInfos(sequence.agents, sequence.stages);
this.buildAgentInfos(sequence.agents);
sequence.stages.forEach(this.prepareMeasurementsStage);
this._resetState(); this._resetState();
this.sizer.performMeasurementsPre();
}
optimisedRenderReflow() {
this.sizer.performMeasurementsAct();
}
optimisedRenderPostReflow(sequence) {
this.visibleAgentIDs = ['[', ']'];
sequence.stages.forEach(this.separationStage);
this._resetState();
this.positionAgents();
sequence.stages.forEach(this.renderStage); sequence.stages.forEach(this.renderStage);
const bottomY = this.checkAgentRange(['[', ']'], this.currentY); const bottomY = this.checkAgentRange(['[', ']'], this.currentY);
const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0); const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
this.updateBounds(stagesHeight); this.updateBounds(stagesHeight);
this.sizer.resetCache(); const prevHighlight = this.currentHighlight;
this.sizer.detach(); this.currentHighlight = -1;
this.setHighlight(prevHighlight); this.setHighlight(prevHighlight);
this.sizer.performMeasurementsPost();
this.sizer.resetCache();
}
render(sequence) {
this.optimisedRenderPreReflow(sequence);
this.optimisedRenderReflow();
this.optimisedRenderPostReflow(sequence);
} }
getThemeNames() { getThemeNames() {

View File

@ -60,6 +60,29 @@ define([
return meta.textContent; return meta.textContent;
} }
function renderAll(diagrams) {
const errors = [];
function storeError(sd, e) {
errors.push(e);
}
diagrams.forEach((diagram) => {
diagram.addEventListener('error', storeError);
diagram.optimisedRenderPreReflow();
});
diagrams.forEach((diagram) => {
diagram.optimisedRenderReflow();
});
diagrams.forEach((diagram) => {
diagram.optimisedRenderPostReflow();
diagram.removeEventListener('error', storeError);
});
if(errors.length > 0) {
throw errors;
}
}
class SequenceDiagram extends EventObject { class SequenceDiagram extends EventObject {
constructor(code = null, options = {}) { constructor(code = null, options = {}) {
super(); super();
@ -85,7 +108,7 @@ define([
if(options.interactive) { if(options.interactive) {
this.addInteractivity(); this.addInteractivity();
} }
if(typeof this.code === 'string') { if(typeof this.code === 'string' && options.render !== false) {
this.render(); this.render();
} }
} }
@ -102,14 +125,16 @@ define([
}, options)); }, options));
} }
set(code = '') { set(code = '', {render = true} = {}) {
if(this.code === code) { if(this.code === code) {
return; return;
} }
this.code = code; this.code = code;
if(render) {
this.render(); this.render();
} }
}
process(code) { process(code) {
const parsed = this.parser.parse(code); const parsed = this.parser.parse(code);
@ -206,29 +231,92 @@ define([
}; };
} }
render(processed = null) { _revertParent(state) {
const dom = this.renderer.svg(); const dom = this.renderer.svg();
const originalParent = dom.parentNode; if(dom.parentNode !== state.originalParent) {
document.body.removeChild(dom);
if(state.originalParent) {
state.originalParent.appendChild(dom);
}
}
}
_sendRenderError(e) {
this._revertParent(this.renderState);
this.renderState.error = true;
this.trigger('error', [this, e]);
}
optimisedRenderPreReflow(processed = null) {
const dom = this.renderer.svg();
this.renderState = {
originalParent: dom.parentNode,
processed,
error: false,
};
const state = this.renderState;
if(!document.body.contains(dom)) { if(!document.body.contains(dom)) {
if(originalParent) { if(state.originalParent) {
originalParent.removeChild(dom); state.originalParent.removeChild(dom);
} }
document.body.appendChild(dom); document.body.appendChild(dom);
} }
try { try {
if(!processed) { if(!state.processed) {
processed = this.process(this.code); state.processed = this.process(this.code);
} }
this.renderer.render(processed); this.renderer.optimisedRenderPreReflow(state.processed);
this.latestProcessed = processed; } catch(e) {
this._sendRenderError(e);
}
}
optimisedRenderReflow() {
try {
if(!this.renderState.error) {
this.renderer.optimisedRenderReflow();
}
} catch(e) {
this._sendRenderError(e);
}
}
optimisedRenderPostReflow() {
const state = this.renderState;
try {
if(!state.error) {
this.renderer.optimisedRenderPostReflow(state.processed);
}
} catch(e) {
this._sendRenderError(e);
}
this.renderState = null;
if(!state.error) {
this._revertParent(state);
this.latestProcessed = state.processed;
this.trigger('render', [this]); this.trigger('render', [this]);
} finally {
if(dom.parentNode !== originalParent) {
document.body.removeChild(dom);
if(originalParent) {
originalParent.appendChild(dom);
} }
} }
render(processed = null) {
let latestError = null;
function storeError(sd, e) {
latestError = e;
}
this.addEventListener('error', storeError);
this.optimisedRenderPreReflow(processed);
this.optimisedRenderReflow();
this.optimisedRenderPostReflow();
this.removeEventListener('error', storeError);
if(latestError) {
throw latestError;
} }
} }
@ -257,6 +345,10 @@ define([
return extractCodeFromSVG(svg); return extractCodeFromSVG(svg);
} }
renderAll(diagrams) {
return renderAll(diagrams);
}
dom() { dom() {
return this.renderer.svg(); return this.renderer.svg();
} }
@ -273,16 +365,13 @@ define([
}; };
} }
function convert(element, code = null, options = {}) { function convertOne(element, code = null, options = {}) {
if(element.tagName === 'svg') { if(element.tagName === 'svg') {
return null; return null;
} }
if(code === null) { if(code === null) {
code = element.innerText; code = element.textContent;
} else if(typeof code === 'object') {
options = code;
code = options.code;
} }
const tagOptions = parseTagOptions(element); const tagOptions = parseTagOptions(element);
@ -304,6 +393,24 @@ define([
return diagram; return diagram;
} }
function convert(elements, code = null, options = {}) {
if(code && typeof code === 'object') {
options = code;
code = options.code;
}
if(Array.isArray(elements)) {
const opts = Object.assign({}, options, {render: false});
const diagrams = elements.map((el) => convertOne(el, code, opts));
if(options.render !== false) {
renderAll(diagrams);
}
return diagrams;
} else {
return convertOne(elements, code, options);
}
}
function convertAll(root = null, className = 'sequence-diagram') { function convertAll(root = null, className = 'sequence-diagram') {
if(typeof root === 'string') { if(typeof root === 'string') {
className = root; className = root;
@ -315,13 +422,15 @@ define([
} else { } else {
elements = (root || document).getElementsByClassName(className); elements = (root || document).getElementsByClassName(className);
} }
// Convert from "live" collection to static to avoid infinite loops: // Convert from "live" collection to static to avoid infinite loops:
const els = []; const els = [];
for(let i = 0; i < elements.length; ++ i) { for(let i = 0; i < elements.length; ++ i) {
els.push(elements[i]); els.push(elements[i]);
} }
// Convert elements // Convert elements
els.forEach((el) => convert(el)); convert(els);
} }
return Object.assign(SequenceDiagram, { return Object.assign(SequenceDiagram, {
@ -334,6 +443,7 @@ define([
addTheme, addTheme,
registerCodeMirrorMode, registerCodeMirrorMode,
extractCodeFromSVG, extractCodeFromSVG,
renderAll,
convert, convert,
convertAll, convertAll,
}); });

View File

@ -20,6 +20,11 @@ define([
return config || env.theme.agentCap.box; return config || env.theme.agentCap.box;
} }
prepareMeasurements({formattedLabel, options}, env) {
const config = this.getConfig(options, env);
env.textSizer.expectMeasure(config.labelAttrs, formattedLabel);
}
separation({formattedLabel, options}, env) { separation({formattedLabel, options}, env) {
const config = this.getConfig(options, env); const config = this.getConfig(options, env);
const width = ( const width = (
@ -78,6 +83,9 @@ define([
} }
class CapCross { class CapCross {
prepareMeasurements() {
}
separation(agentInfo, env) { separation(agentInfo, env) {
const config = env.theme.agentCap.cross; const config = env.theme.agentCap.cross;
return { return {
@ -122,6 +130,11 @@ define([
} }
class CapBar { class CapBar {
prepareMeasurements({formattedLabel}, env) {
const config = env.theme.agentCap.box;
env.textSizer.expectMeasure(config.labelAttrs, formattedLabel);
}
separation({formattedLabel}, env) { separation({formattedLabel}, env) {
const config = env.theme.agentCap.box; const config = env.theme.agentCap.box;
const width = ( const width = (
@ -178,6 +191,9 @@ define([
} }
class CapFade { class CapFade {
prepareMeasurements() {
}
separation({currentRad}) { separation({currentRad}) {
return { return {
left: currentRad, left: currentRad,
@ -239,6 +255,9 @@ define([
} }
class CapNone { class CapNone {
prepareMeasurements() {
}
separation({currentRad}) { separation({currentRad}) {
return { return {
left: currentRad, left: currentRad,
@ -287,6 +306,14 @@ define([
this.begin = begin; this.begin = begin;
} }
prepareMeasurements({mode, agentIDs}, env) {
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
const cap = AGENT_CAPS[mode];
cap.prepareMeasurements(agentInfo, env, this.begin);
});
}
separationPre({mode, agentIDs}, env) { separationPre({mode, agentIDs}, env) {
agentIDs.forEach((id) => { agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id); const agentInfo = env.agentInfos.get(id);

View File

@ -9,6 +9,16 @@ define(() => {
this.makeState(state); this.makeState(state);
} }
prepareMeasurements(/*stage, {
renderer,
theme,
agentInfos,
textSizer,
state,
components,
}*/) {
}
separationPre(/*stage, { separationPre(/*stage, {
renderer, renderer,
theme, theme,

View File

@ -12,6 +12,13 @@ define([
'use strict'; 'use strict';
class BlockSplit extends BaseComponent { class BlockSplit extends BaseComponent {
prepareMeasurements({left, tag, label}, env) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.type).section;
env.textSizer.expectMeasure(config.tag.labelAttrs, tag);
env.textSizer.expectMeasure(config.label.labelAttrs, label);
}
separation({left, right, tag, label}, env) { separation({left, right, tag, label}, env) {
const blockInfo = env.state.blocks.get(left); const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.type).section; const config = env.theme.getBlock(blockInfo.type).section;
@ -126,8 +133,14 @@ define([
return blockInfo; return blockInfo;
} }
prepareMeasurements(stage, env) {
this.storeBlockInfo(stage, env);
super.prepareMeasurements(stage, env);
}
separationPre(stage, env) { separationPre(stage, env) {
this.storeBlockInfo(stage, env); this.storeBlockInfo(stage, env);
super.separationPre(stage, env);
} }
separation(stage, env) { separation(stage, env) {

View File

@ -110,6 +110,15 @@ define([
]; ];
class Connect extends BaseComponent { class Connect extends BaseComponent {
prepareMeasurements({agentIDs, label}, env) {
const config = env.theme.connect;
const loopback = (agentIDs[0] === agentIDs[1]);
const labelAttrs = (loopback ?
config.label.loopbackAttrs : config.label.attrs);
env.textSizer.expectMeasure(labelAttrs, label);
}
separationPre({agentIDs}, env) { separationPre({agentIDs}, env) {
const r = env.theme.connect.source.radius; const r = env.theme.connect.source.radius;
agentIDs.forEach((id) => { agentIDs.forEach((id) => {
@ -128,15 +137,17 @@ define([
const lArrow = ARROWHEADS[options.left]; const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right]; const rArrow = ARROWHEADS[options.right];
let labelWidth = ( const loopback = (agentIDs[0] === agentIDs[1]);
env.textSizer.measure(config.label.attrs, label).width const labelAttrs = (loopback ?
); config.label.loopbackAttrs : config.label.attrs);
let labelWidth = env.textSizer.measure(labelAttrs, label).width;
if(labelWidth > 0) { if(labelWidth > 0) {
labelWidth += config.label.padding * 2; labelWidth += config.label.padding * 2;
} }
const info1 = env.agentInfos.get(agentIDs[0]); const info1 = env.agentInfos.get(agentIDs[0]);
if(agentIDs[0] === agentIDs[1]) { if(loopback) {
env.addSpacing(agentIDs[0], { env.addSpacing(agentIDs[0], {
left: 0, left: 0,
right: ( right: (
@ -490,6 +501,8 @@ define([
} }
class ConnectDelayEnd extends Connect { class ConnectDelayEnd extends Connect {
prepareMeasurements() {}
separationPre() {} separationPre() {}
separation() {} separation() {}

View File

@ -10,6 +10,11 @@ define([
'use strict'; 'use strict';
class Divider extends BaseComponent { class Divider extends BaseComponent {
prepareMeasurements({mode, formattedLabel}, env) {
const config = env.theme.getDivider(mode);
env.textSizer.expectMeasure(config.labelAttrs, formattedLabel);
}
separation({mode, formattedLabel}, env) { separation({mode, formattedLabel}, env) {
const config = env.theme.getDivider(mode); const config = env.theme.getDivider(mode);

View File

@ -20,6 +20,11 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
} }
class NoteComponent extends BaseComponent { class NoteComponent extends BaseComponent {
prepareMeasurements({mode, label}, env) {
const config = env.theme.getNote(mode);
env.textSizer.expectMeasure(config.labelAttrs, label);
}
renderPre({agentIDs}) { renderPre({agentIDs}) {
return {agentIDs}; return {agentIDs};
} }

View File

@ -28,16 +28,23 @@ define([
} }
class Parallel extends BaseComponent { class Parallel extends BaseComponent {
separationPre(stage, env) { invokeChildren(stage, env, methodName) {
stage.stages.forEach((subStage) => { return stage.stages.map((subStage) => {
env.components.get(subStage.type).separationPre(subStage, env); const component = env.components.get(subStage.type);
return component[methodName](subStage, env);
}); });
} }
prepareMeasurements(stage, env) {
this.invokeChildren(stage, env, 'prepareMeasurements');
}
separationPre(stage, env) {
this.invokeChildren(stage, env, 'separationPre');
}
separation(stage, env) { separation(stage, env) {
stage.stages.forEach((subStage) => { this.invokeChildren(stage, env, 'separation');
env.components.get(subStage.type).separation(subStage, env);
});
} }
renderPre(stage, env) { renderPre(stage, env) {
@ -47,11 +54,9 @@ define([
asynchronousY: null, asynchronousY: null,
}; };
return stage.stages.map((subStage) => { return this.invokeChildren(stage, env, 'renderPre')
const component = env.components.get(subStage.type); .map((r) => BaseComponent.cleanRenderPreResult(r))
const subResult = component.renderPre(subStage, env); .reduce(mergeResults, baseResults);
return BaseComponent.cleanRenderPreResult(subResult);
}).reduce(mergeResults, baseResults);
} }
render(stage, env) { render(stage, env) {
@ -73,24 +78,19 @@ define([
} }
renderHidden(stage, env) { renderHidden(stage, env) {
stage.stages.forEach((subStage) => { this.invokeChildren(stage, env, 'renderHidden');
const component = env.components.get(subStage.type);
component.renderHidden(subStage, env);
});
} }
shouldHide(stage, env) { shouldHide(stage, env) {
const result = { const baseResults = {
self: false, self: false,
nest: 0, nest: 0,
}; };
stage.stages.forEach((subStage) => { return this.invokeChildren(stage, env, 'shouldHide')
const component = env.components.get(subStage.type); .reduce((result, {self = false, nest = 0} = {}) => ({
const hide = component.shouldHide(subStage, env) || {}; self: result.self || Boolean(self),
result.self = (result.self || Boolean(hide.self)); nest: result.nest + nest,
result.nest += (hide.nest || 0); }), baseResults);
});
return result;
} }
} }

View File

@ -491,8 +491,8 @@ define([
// but this fails when exporting as SVG / PNG (svg tags must // but this fails when exporting as SVG / PNG (svg tags must
// have no external dependencies). // have no external dependencies).
// const url = 'https://fonts.googleapis.com/css?family=' + FONT; // const url = 'https://fonts.googleapis.com/css?family=' + FONT;
// style.innerText = '@import url("' + url + '")'; // style.textContent = '@import url("' + url + '")';
style.innerText = ( style.textContent = (
'@font-face{' + '@font-face{' +
'font-family:"' + Handlee.name + '";' + 'font-family:"' + Handlee.name + '";' +
'src:url("data:font/woff2;base64,' + Handlee.woff2 + '");' + 'src:url("data:font/woff2;base64,' + Handlee.woff2 + '");' +

View File

@ -114,25 +114,60 @@ define(['svg/SVGUtilities'], (svg) => {
} }
class SizeTester { class SizeTester {
measure(attrs, formatted) { constructor() {
if(attrs.state) { this.expected = new Set();
formatted = attrs.state.formatted; this.measured = new Set();
attrs = attrs.state.attrs;
} }
if(!formatted || !formatted.length) { _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.expected.add(JSON.stringify(opts));
}
performMeasurementsPre() {
}
performMeasurementsAct() {
this.measured = new Set(this.expected);
}
performMeasurementsPost() {
}
measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
if(!this.measured.has(JSON.stringify(opts))) {
throw new Error('Unexpected measurement', opts);
}
if(!opts.formatted || !opts.formatted.length) {
return {width: 0, height: 0}; return {width: 0, height: 0};
} }
let width = 0; let width = 0;
formatted.forEach((line) => { opts.formatted.forEach((line) => {
const length = line.reduce((v, pt) => v + pt.text.length, 0); const length = line.reduce((v, pt) => v + pt.text.length, 0);
width = Math.max(width, length); width = Math.max(width, length);
}); });
return { return {
width, width,
height: formatted.length, height: opts.formatted.length,
}; };
} }
@ -145,9 +180,8 @@ define(['svg/SVGUtilities'], (svg) => {
} }
resetCache() { resetCache() {
} this.expected.clear();
this.measured.clear();
detach() {
} }
} }

View File

@ -38,27 +38,6 @@ 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 {
@ -175,35 +154,68 @@ define(['./SVGUtilities'], (svg) => {
}); });
this.container = container; this.container = container;
this.cache = new Map(); 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}) { _measureHeight({attrs, formatted}) {
return formatted.length * fontDetails(attrs).lineHeight; return formatted.length * fontDetails(attrs).lineHeight;
} }
_measureWidth({attrs, formatted}) { _measureLine(attrCache, line) {
if(!formatted.length) { if(!line.length) {
return 0; return 0;
} }
const attrKey = JSON.stringify(attrs); const labelKey = JSON.stringify(line);
let tester = this.cache.get(attrKey); const cache = attrCache.lines.get(labelKey);
if(!tester) { if(cache.width === null) {
const node = svg.make('text', attrs); window.console.warn('Performing unexpected measurement', line);
this.testers.appendChild(node); this.performMeasurements();
tester = { }
node, return cache.width;
widths: new Map(),
};
this.cache.set(attrKey, tester);
} }
if(!this.testers.parentNode) { _measureWidth(opts) {
this.container.appendChild(this.testers); if(!opts.formatted.length) {
return 0;
} }
return (formatted const attrCache = this._expectMeasure(opts);
.map((line) => measureLine(tester, line))
return (opts.formatted
.map((line) => this._measureLine(attrCache, line))
.reduce((a, b) => Math.max(a, b), 0) .reduce((a, b) => Math.max(a, b), 0)
); );
} }
@ -222,6 +234,52 @@ define(['./SVGUtilities'], (svg) => {
return {attrs, 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) { measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted); const opts = this._getMeasurementOpts(attrs, formatted);
return { return {
@ -236,15 +294,8 @@ define(['./SVGUtilities'], (svg) => {
} }
resetCache() { resetCache() {
svg.empty(this.testers);
this.cache.clear(); this.cache.clear();
} }
detach() {
if(this.testers.parentNode) {
this.container.removeChild(this.testers);
}
}
} }
SVGTextBlock.SizeTester = SizeTester; SVGTextBlock.SizeTester = SizeTester;

View File

@ -206,23 +206,5 @@ defineDescribe('SVGTextBlock', [
expect(hold.children.length).toEqual(0); expect(hold.children.length).toEqual(0);
}); });
}); });
describe('.detach', () => {
it('removes the test node from the DOM', () => {
tester.measure(attrs, [[{text: 'foo'}]]);
expect(hold.children.length).toEqual(1);
tester.detach();
expect(hold.children.length).toEqual(0);
});
it('does not prevent using the tester again later', () => {
tester.measure(attrs, [[{text: 'foo'}]]);
tester.detach();
const size = tester.measure(attrs, [[{text: 'foo'}]]);
expect(hold.children.length).toEqual(1);
expect(size.width).toBeGreaterThan(0);
});
});
}); });
}); });