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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@ -99,6 +99,8 @@ define([
_bindMethods() {
this.separationStage = this.separationStage.bind(this);
this.prepareMeasurementsStage =
this.prepareMeasurementsStage.bind(this);
this.renderStage = this.renderStage.bind(this);
this.addThemeDef = this.addThemeDef.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) {
if(agentIDs.length === 0) {
return topY;
@ -467,7 +487,7 @@ define([
});
}
buildAgentInfos(agents, stages) {
buildAgentInfos(agents) {
this.agentInfos = new Map();
agents.forEach((agent, index) => {
this.agentInfos.set(agent.id, {
@ -487,11 +507,6 @@ define([
separations: new Map(),
});
});
this.visibleAgentIDs = ['[', ']'];
stages.forEach(this.separationStage);
this.positionAgents();
}
updateBounds(stagesHeight) {
@ -623,16 +638,18 @@ define([
return this._setCollapsed(line, collapsed);
}
render(sequence) {
const prevHighlight = this.currentHighlight;
_switchTheme(name) {
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.theme.reset();
this.metaCode.nodeValue = sequence.meta.code;
this.theme.addDefs(this.addThemeDef);
@ -640,21 +657,47 @@ define([
attrs: this.theme.titleAttrs,
formatted: sequence.meta.title,
});
this.sizer.expectMeasure(this.title);
this.minX = 0;
this.maxX = 0;
this.buildAgentInfos(sequence.agents, sequence.stages);
this.buildAgentInfos(sequence.agents);
sequence.stages.forEach(this.prepareMeasurementsStage);
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);
const bottomY = this.checkAgentRange(['[', ']'], this.currentY);
const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
this.updateBounds(stagesHeight);
this.sizer.resetCache();
this.sizer.detach();
const prevHighlight = this.currentHighlight;
this.currentHighlight = -1;
this.setHighlight(prevHighlight);
this.sizer.performMeasurementsPost();
this.sizer.resetCache();
}
render(sequence) {
this.optimisedRenderPreReflow(sequence);
this.optimisedRenderReflow();
this.optimisedRenderPostReflow(sequence);
}
getThemeNames() {

View File

@ -60,6 +60,29 @@ define([
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 {
constructor(code = null, options = {}) {
super();
@ -85,7 +108,7 @@ define([
if(options.interactive) {
this.addInteractivity();
}
if(typeof this.code === 'string') {
if(typeof this.code === 'string' && options.render !== false) {
this.render();
}
}
@ -102,14 +125,16 @@ define([
}, options));
}
set(code = '') {
set(code = '', {render = true} = {}) {
if(this.code === code) {
return;
}
this.code = code;
if(render) {
this.render();
}
}
process(code) {
const parsed = this.parser.parse(code);
@ -206,29 +231,92 @@ define([
};
}
render(processed = null) {
_revertParent(state) {
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(originalParent) {
originalParent.removeChild(dom);
if(state.originalParent) {
state.originalParent.removeChild(dom);
}
document.body.appendChild(dom);
}
try {
if(!processed) {
processed = this.process(this.code);
if(!state.processed) {
state.processed = this.process(this.code);
}
this.renderer.render(processed);
this.latestProcessed = processed;
this.renderer.optimisedRenderPreReflow(state.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]);
} 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);
}
renderAll(diagrams) {
return renderAll(diagrams);
}
dom() {
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') {
return null;
}
if(code === null) {
code = element.innerText;
} else if(typeof code === 'object') {
options = code;
code = options.code;
code = element.textContent;
}
const tagOptions = parseTagOptions(element);
@ -304,6 +393,24 @@ define([
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') {
if(typeof root === 'string') {
className = root;
@ -315,13 +422,15 @@ define([
} else {
elements = (root || document).getElementsByClassName(className);
}
// Convert from "live" collection to static to avoid infinite loops:
const els = [];
for(let i = 0; i < elements.length; ++ i) {
els.push(elements[i]);
}
// Convert elements
els.forEach((el) => convert(el));
convert(els);
}
return Object.assign(SequenceDiagram, {
@ -334,6 +443,7 @@ define([
addTheme,
registerCodeMirrorMode,
extractCodeFromSVG,
renderAll,
convert,
convertAll,
});

View File

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

View File

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

View File

@ -12,6 +12,13 @@ define([
'use strict';
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) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.type).section;
@ -126,8 +133,14 @@ define([
return blockInfo;
}
prepareMeasurements(stage, env) {
this.storeBlockInfo(stage, env);
super.prepareMeasurements(stage, env);
}
separationPre(stage, env) {
this.storeBlockInfo(stage, env);
super.separationPre(stage, env);
}
separation(stage, env) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -206,23 +206,5 @@ defineDescribe('SVGTextBlock', [
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);
});
});
});
});