Large refactoring to unpick direct DOM access [#32]

This commit is contained in:
David Evans 2018-04-15 12:52:42 +01:00
parent 42ea5d1bf9
commit a5f32d34d8
44 changed files with 6617 additions and 6012 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -419,6 +419,10 @@ Creates a new SequenceDiagram object. Options is an object which can contain:
separate argument.</li>
<li><code>container</code>: DOM node to append the diagram to (defaults to
null).</li>
<li><code>document</code>: Document object to base the diagram in (defaults to
container's document, or <code>window.document</code>).</li>
<li><code>textSizerFactory</code>: Function which returns an object capable of
measuring text (defaults to wrapping <code>getComputedTextLength</code>).</li>
<li><code>themes</code>: List of themes to make available to the diagram
(defaults to globally registered themes).</li>
<li><code>namespace</code>: Each diagram on a page must have a unique namespace.

208
scripts/core/DOMWrapper.js Normal file
View File

@ -0,0 +1,208 @@
define(() => {
'use strict';
function make(value, document) {
if(typeof value === 'string') {
return document.createTextNode(value);
} else if(typeof value === 'number') {
return document.createTextNode(value.toString(10));
} else if(typeof value === 'object' && value.element) {
return value.element;
} else {
return value;
}
}
function unwrap(node) {
if(node === null) {
return null;
} else if(node.element) {
return node.element;
} else {
return node;
}
}
class WrappedElement {
constructor(element) {
this.element = element;
}
addBefore(child = null, before = null) {
if(child === null) {
return this;
} else if(Array.isArray(child)) {
for(const c of child) {
this.addBefore(c, before);
}
} else {
const childElement = make(child, this.element.ownerDocument);
this.element.insertBefore(childElement, unwrap(before));
}
return this;
}
add(...child) {
return this.addBefore(child, null);
}
del(child = null) {
if(child !== null) {
this.element.removeChild(unwrap(child));
}
return this;
}
attr(key, value) {
this.element.setAttribute(key, value);
return this;
}
attrs(attrs) {
for(const k in attrs) {
if(attrs.hasOwnProperty(k)) {
this.element.setAttribute(k, attrs[k]);
}
}
return this;
}
styles(styles) {
for(const k in styles) {
if(styles.hasOwnProperty(k)) {
this.element.style[k] = styles[k];
}
}
return this;
}
setClass(cls) {
return this.attr('class', cls);
}
addClass(cls) {
const classes = this.element.getAttribute('class');
if(!classes) {
return this.setClass(cls);
}
const list = classes.split(' ');
if(list.includes(cls)) {
return this;
}
list.push(cls);
return this.attr('class', list.join(' '));
}
delClass(cls) {
const classes = this.element.getAttribute('class');
if(!classes) {
return this;
}
const list = classes.split(' ');
const p = list.indexOf(cls);
if(p !== -1) {
list.splice(p, 1);
this.attr('class', list.join(' '));
}
return this;
}
text(text) {
this.element.textContent = text;
return this;
}
on(event, callback, options = {}) {
if(Array.isArray(event)) {
for(const e of event) {
this.on(e, callback, options);
}
} else {
this.element.addEventListener(event, callback, options);
}
return this;
}
off(event, callback, options = {}) {
if(Array.isArray(event)) {
for(const e of event) {
this.off(e, callback, options);
}
} else {
this.element.removeEventListener(event, callback, options);
}
return this;
}
val(value) {
this.element.value = value;
return this;
}
select(start, end = null) {
this.element.selectionStart = start;
this.element.selectionEnd = (end === null) ? start : end;
return this;
}
focus() {
this.element.focus();
return this;
}
focussed() {
return this.element === this.element.ownerDocument.activeElement;
}
empty() {
while(this.element.childNodes.length > 0) {
this.element.removeChild(this.element.lastChild);
}
return this;
}
attach(parent) {
unwrap(parent).appendChild(this.element);
return this;
}
detach() {
this.element.parentNode.removeChild(this.element);
return this;
}
}
return class DOMWrapper {
constructor(document) {
if(!document) {
throw new Error('Missing document!');
}
this.document = document;
this.wrap = this.wrap.bind(this);
this.el = this.el.bind(this);
this.txt = this.txt.bind(this);
}
wrap(element) {
if(element.element) {
return element;
} else {
return new WrappedElement(element);
}
}
el(tag, namespace = null) {
let element = null;
if(namespace === null) {
element = this.document.createElement(tag);
} else {
element = this.document.createElementNS(namespace, tag);
}
return new WrappedElement(element);
}
txt(content = '') {
return this.document.createTextNode(content);
}
};
});

View File

@ -27,6 +27,16 @@ define(() => {
}
}
on(type, fn) {
this.addEventListener(type, fn);
return this;
}
off(type, fn) {
this.removeEventListener(type, fn);
return this;
}
countEventListeners(type) {
return (this.listeners.get(type) || []).length;
}

41
scripts/core/Random.js Normal file
View File

@ -0,0 +1,41 @@
define(() => {
'use strict';
return class Random {
// xorshift+ 64-bit random generator
// https://en.wikipedia.org/wiki/Xorshift
constructor() {
this.s = new Uint32Array(4);
}
reset() {
// Arbitrary random seed with roughly balanced 1s / 0s
// (taken from running Math.random a few times)
this.s[0] = 0x177E9C74;
this.s[1] = 0xAE6FFDCE;
this.s[2] = 0x3CF4F32B;
this.s[3] = 0x46449F88;
}
nextFloat() {
/* jshint -W016 */ // bit-operations are part of the algorithm
const range = 0x100000000;
let x0 = this.s[0];
let x1 = this.s[1];
const y0 = this.s[2];
const y1 = this.s[3];
this.s[0] = y0;
this.s[1] = y1;
x0 ^= (x0 << 23) | (x1 >>> 9);
x1 ^= (x1 << 23);
this.s[2] = x0 ^ y0 ^ (x0 >>> 17) ^ (y0 >>> 26);
this.s[3] = (
x1 ^ y1 ^
(x0 << 15 | x1 >>> 17) ^
(y0 << 6 | y1 >>> 26)
);
return (((this.s[3] + y1) >>> 0) % range) / range;
}
};
});

View File

@ -0,0 +1,47 @@
defineDescribe('Random', ['./Random'], (Random) => {
'use strict';
let random = null;
beforeEach(() => {
random = new Random();
random.reset();
});
describe('.nextFloat', () => {
it('produces values between 0 and 1', () => {
for(let i = 0; i < 1000; ++ i) {
const v = random.nextFloat();
expect(v).not.toBeLessThan(0);
expect(v).toBeLessThan(1);
}
});
it('produces the same sequence when reset', () => {
const values = [];
for(let i = 0; i < 1000; ++ i) {
values.push(random.nextFloat());
}
random.reset();
for(let i = 0; i < 1000; ++ i) {
expect(random.nextFloat()).toEqual(values[i]);
}
});
it('produces a roughly uniform range of values', () => {
const samples = 10000;
const granularity = 10;
const buckets = [];
buckets.length = granularity;
buckets.fill(0);
for(let i = 0; i < samples; ++ i) {
const v = random.nextFloat() * granularity;
++ buckets[Math.floor(v)];
}
const threshold = (samples / granularity) * 0.9;
for(let i = 0; i < granularity; ++ i) {
expect(buckets[i]).not.toBeLessThan(threshold);
}
});
});
});

View File

@ -0,0 +1,211 @@
define(() => {
'use strict';
function encodeChar(c) {
return '&#' + c.charCodeAt(0).toString(10) + ';';
}
function escapeHTML(text) {
return text.replace(/[^\r\n\t -%'-;=?-~]/g, encodeChar);
}
function escapeQuoted(text) {
return text.replace(/[^\r\n\t !#$%(-;=?-~]/g, encodeChar);
}
class TextNode {
constructor(content) {
this.parentNode = null;
this.nodeValue = content;
}
contains() {
return false;
}
get textContent() {
return this.nodeValue;
}
set textContent(value) {
this.nodeValue = value;
}
get isConnected() {
if(this.parentNode !== null) {
return this.parentNode.isConnected;
}
return false;
}
get innerHTML() {
return escapeHTML(this.nodeValue);
}
get outerHTML() {
return this.innerHTML;
}
}
class ElementNode {
constructor(ownerDocument, tag, namespace) {
this.ownerDocument = ownerDocument;
this.tagName = tag;
this.namespaceURI = namespace;
this.parentNode = null;
this.childNodes = [];
this.attributes = new Map();
this.listeners = new Map();
}
setAttribute(key, value) {
if(typeof value === 'number') {
value = value.toString(10);
} else if(typeof value !== 'string') {
throw new Error('Bad value ' + value + ' for attribute ' + key);
}
this.attributes.set(key, value);
}
getAttribute(key) {
return this.attributes.get(key);
}
addEventListener(event, fn) {
let list = this.listeners.get(event);
if(!list) {
list = [];
this.listeners.set(event, list);
}
list.push(fn);
}
removeEventListener(event, fn) {
const list = this.listeners.get(event) || [];
const index = list.indexOf(fn);
if(index !== -1) {
list.splice(index, 1);
}
}
dispatchEvent(e) {
const list = this.listeners.get(e.type) || [];
list.forEach((fn) => fn(e));
}
contains(descendant) {
let check = descendant;
while(check) {
if(check === this) {
return true;
}
check = check.parentNode;
}
return false;
}
get firstChild() {
return this.childNodes[0] || null;
}
get lastChild() {
return this.childNodes[this.childNodes.length - 1] || null;
}
indexOf(child) {
const index = this.childNodes.indexOf(child);
if(index === -1) {
throw new Error(child + ' is not a child of ' + this);
}
return index;
}
insertBefore(child, existingChild) {
if(child.contains(this)) {
throw new Error('Cyclic node structures are not permitted');
}
if(child.parentNode !== null) {
child.parentNode.removeChild(child);
}
if(existingChild === null) {
this.childNodes.push(child);
} else {
this.childNodes.splice(this.indexOf(existingChild), 0, child);
}
child.parentNode = this;
return child;
}
appendChild(child) {
return this.insertBefore(child, null);
}
removeChild(child) {
this.childNodes.splice(this.indexOf(child), 1);
child.parentNode = null;
return child;
}
replaceChild(newChild, oldChild) {
if(newChild === oldChild) {
return oldChild;
}
this.insertBefore(newChild, oldChild);
return this.removeChild(oldChild);
}
get isConnected() {
return true;
}
get textContent() {
let text = '';
for(const child of this.childNodes) {
text += child.textContent;
}
return text;
}
set textContent(value) {
for(const child of this.childNodes) {
child.parentNode = null;
}
this.childNodes.length = 0;
this.appendChild(new TextNode(value));
}
get innerHTML() {
let html = '';
for(const child of this.childNodes) {
html += child.outerHTML;
}
return html;
}
get outerHTML() {
let attrs = '';
for(const [key, value] of this.attributes) {
attrs += ' ' + key + '="' + escapeQuoted(value) + '"';
}
return (
'<' + this.tagName + attrs + '>' +
this.innerHTML +
'</' + this.tagName + '>'
);
}
}
return class VirtualDocument {
createElement(tag) {
return new ElementNode(this, tag, '');
}
createElementNS(ns, tag) {
return new ElementNode(this, tag, ns || '');
}
createTextNode(content) {
return new TextNode(content);
}
};
});

View File

@ -0,0 +1,260 @@
defineDescribe('VirtualDocument', ['./VirtualDocument'], (VirtualDocument) => {
'use strict';
const doc = new VirtualDocument();
describe('createElement', () => {
it('creates elements which conform to the DOM API', () => {
const o = doc.createElement('div');
expect(o.ownerDocument).toEqual(doc);
expect(o.tagName).toEqual('div');
expect(o.namespaceURI).toEqual('');
expect(o.parentNode).toEqual(null);
expect(o.childNodes.length).toEqual(0);
});
it('claims all elements are always connected', () => {
const o = doc.createElement('div');
expect(o.isConnected).toEqual(true);
});
});
describe('appendChild', () => {
it('adds a child to the element', () => {
const o = doc.createElement('div');
o.appendChild(doc.createElement('span'));
expect(o.childNodes.length).toEqual(1);
expect(o.childNodes[0].tagName).toEqual('span');
});
it('removes the child from its old parent', () => {
const o = doc.createElement('div');
const oldParent = doc.createElement('div');
const child = doc.createElement('span');
oldParent.appendChild(child);
o.appendChild(child);
expect(oldParent.childNodes.length).toEqual(0);
});
it('rejects loops', () => {
const o1 = doc.createElement('div');
const o2 = doc.createElement('div');
const o3 = doc.createElement('div');
o1.appendChild(o2);
o2.appendChild(o3);
expect(() => o3.appendChild(o1)).toThrow();
});
});
describe('removeChild', () => {
it('removes a child from the element', () => {
const o = doc.createElement('div');
const child = doc.createElement('span');
o.appendChild(child);
o.removeChild(child);
expect(o.childNodes.length).toEqual(0);
});
it('rejects nodes which are not children', () => {
const o = doc.createElement('div');
const child = doc.createElement('span');
expect(() => o.removeChild(child)).toThrow();
});
});
describe('firstChild', () => {
it('returns the first child of the node', () => {
const o = doc.createElement('div');
o.appendChild(doc.createElement('a'));
o.appendChild(doc.createElement('b'));
expect(o.firstChild.tagName).toEqual('a');
});
it('returns null if there are no children', () => {
const o = doc.createElement('div');
expect(o.firstChild).toEqual(null);
});
});
describe('lastChild', () => {
it('returns the last child of the node', () => {
const o = doc.createElement('div');
o.appendChild(doc.createElement('a'));
o.appendChild(doc.createElement('b'));
expect(o.lastChild.tagName).toEqual('b');
});
it('returns null if there are no children', () => {
const o = doc.createElement('div');
expect(o.lastChild).toEqual(null);
});
});
describe('contains', () => {
it('returns true if the given node is within the current node', () => {
const o = doc.createElement('div');
const child = doc.createElement('div');
o.appendChild(child);
expect(o.contains(child)).toEqual(true);
});
it('performs a deep check', () => {
const o = doc.createElement('div');
const middle = doc.createElement('div');
const child = doc.createElement('div');
o.appendChild(middle);
middle.appendChild(child);
expect(o.contains(child)).toEqual(true);
});
it('returns true if the nodes are the same', () => {
const o = doc.createElement('div');
expect(o.contains(o)).toEqual(true);
});
it('returns false if the node is not within the current node', () => {
const o = doc.createElement('div');
const o2 = doc.createElement('div');
expect(o.contains(o2)).toEqual(false);
});
});
describe('textContent', () => {
it('replaces the content of the element', () => {
const o = doc.createElement('div');
o.appendChild(doc.createElement('span'));
o.textContent = 'foo';
expect(o.innerHTML).toEqual('foo');
});
it('returns the text content of all child nodes', () => {
const o = doc.createElement('div');
const child = doc.createElement('span');
o.appendChild(doc.createTextNode('abc'));
o.appendChild(child);
child.appendChild(doc.createTextNode('def'));
o.appendChild(doc.createTextNode('ghi'));
expect(o.textContent).toEqual('abcdefghi');
});
});
describe('attributes', () => {
it('keeps a key/value map of attributes', () => {
const o = doc.createElement('div');
o.setAttribute('foo', 'bar');
o.setAttribute('zig', 'zag');
o.setAttribute('foo', 'baz');
expect(o.getAttribute('foo')).toEqual('baz');
expect(o.getAttribute('zig')).toEqual('zag');
expect(o.getAttribute('nope')).toEqual(undefined);
});
});
describe('events', () => {
let o = null;
let called = null;
let fn = null;
beforeEach(() => {
o = doc.createElement('div');
called = 0;
fn = () => {
++ called;
};
});
it('stores and triggers event listeners', () => {
o.addEventListener('foo', fn);
o.dispatchEvent(new Event('foo'));
expect(called).toEqual(1);
});
it('removes listeners when removeEventListener is called', () => {
o.addEventListener('foo', fn);
o.removeEventListener('foo', fn);
o.dispatchEvent(new Event('foo'));
expect(called).toEqual(0);
});
it('stores multiple event listeners', () => {
const fn2 = () => {
called += 10;
};
o.addEventListener('foo', fn);
o.addEventListener('foo', fn2);
o.dispatchEvent(new Event('foo'));
expect(called).toEqual(11);
});
it('invokes listeners according to their type', () => {
o.addEventListener('foo', fn);
o.dispatchEvent(new Event('bar'));
expect(called).toEqual(0);
});
});
describe('outerHTML', () => {
it('returns the tag in HTML form', () => {
const o = doc.createElement('div');
expect(o.outerHTML).toEqual('<div></div>');
});
it('includes attributes', () => {
const o = doc.createElement('div');
o.setAttribute('foo', 'bar');
o.setAttribute('zig', 'zag');
expect(o.outerHTML).toEqual('<div foo="bar" zig="zag"></div>');
});
it('escapes attributes', () => {
const o = doc.createElement('div');
o.setAttribute('foo', 'b&a"r');
expect(o.outerHTML).toEqual('<div foo="b&#38;a&#34;r"></div>');
});
it('includes all children', () => {
const o = doc.createElement('div');
const child = doc.createElement('span');
o.appendChild(doc.createTextNode('abc'));
o.appendChild(child);
child.appendChild(doc.createTextNode('def'));
o.appendChild(doc.createTextNode('ghi'));
expect(o.outerHTML).toEqual('<div>abc<span>def</span>ghi</div>');
});
it('escapes text content', () => {
const o = doc.createElement('div');
o.appendChild(doc.createTextNode('a<b>c'));
expect(o.outerHTML).toEqual('<div>a&#60;b&#62;c</div>');
});
});
});

View File

@ -1,26 +1,11 @@
define(['require'], (require) => {
define(['require', 'core/DOMWrapper'], (require, DOMWrapper) => {
'use strict';
const DELAY_AGENTCHANGE = 500;
const DELAY_STAGECHANGE = 250;
const PNG_RESOLUTION = 4;
function makeText(text = '') {
return document.createTextNode(text);
}
function makeNode(type, attrs = {}, children = []) {
const o = document.createElement(type);
for(const k in attrs) {
if(attrs.hasOwnProperty(k)) {
o.setAttribute(k, attrs[k]);
}
}
for(const c of children) {
o.appendChild(c);
}
return o;
}
const dom = new DOMWrapper(document);
function addNewline(value) {
if(value.length > 0 && value.charAt(value.length - 1) !== '\n') {
@ -29,10 +14,6 @@ define(['require'], (require) => {
return value;
}
function on(element, events, fn) {
events.forEach((event) => element.addEventListener(event, fn));
}
function findPos(content, index) {
let p = 0;
let line = 0;
@ -166,196 +147,140 @@ define(['require'], (require) => {
this._showDropStyle = this._showDropStyle.bind(this);
this._hideDropStyle = this._hideDropStyle.bind(this);
this._enhanceEditor();
}
buildOptionsLinks() {
const options = makeNode('div', {'class': 'options links'});
this.links.forEach((link) => {
options.appendChild(makeNode('a', {
'href': link.href,
'target': '_blank',
}, [makeText(link.label)]));
});
return options;
this.diagram
.on('render', () => {
this.updateMinSize(this.diagram.getSize());
this.pngDirty = true;
})
.on('mouseover', (element) => {
if(this.marker) {
this.marker.clear();
}
if(element.ln !== undefined && this.code.markText) {
this.marker = this.code.markText(
{line: element.ln, ch: 0},
{line: element.ln + 1, ch: 0},
{
className: 'hover',
inclusiveLeft: false,
inclusiveRight: false,
clearOnEnter: true,
}
);
}
})
.on('mouseout', () => {
if(this.marker) {
this.marker.clear();
this.marker = null;
}
})
.on('click', (element) => {
if(this.marker) {
this.marker.clear();
this.marker = null;
}
if(element.ln !== undefined && this.code.setSelection) {
this.code.setSelection(
{line: element.ln, ch: 0},
{line: element.ln + 1, ch: 0},
{origin: '+focus', bias: -1}
);
this.code.focus();
}
})
.on('dblclick', (element) => {
this.diagram.toggleCollapsed(element.ln);
});
}
buildOptionsDownloads() {
this.downloadPNG = makeNode('a', {
'href': '#',
'download': 'SequenceDiagram.png',
}, [makeText('Download PNG')]);
on(this.downloadPNG, [
'focus',
'mouseover',
'mousedown',
], this._downloadPNGFocus);
on(this.downloadPNG, ['click'], this._downloadPNGClick);
this.downloadPNG = dom.el('a')
.text('Download PNG')
.attrs({
'href': '#',
'download': 'SequenceDiagram.png',
})
.on(['focus', 'mouseover', 'mousedown'], this._downloadPNGFocus)
.on('click', this._downloadPNGClick);
this.downloadSVG = makeNode('a', {
'href': '#',
'download': 'SequenceDiagram.svg',
}, [makeText('SVG')]);
on(this.downloadSVG, ['click'], this._downloadSVGClick);
this.downloadSVG = dom.el('a')
.text('SVG')
.attrs({
'href': '#',
'download': 'SequenceDiagram.svg',
})
.on('click', this._downloadSVGClick);
return makeNode('div', {'class': 'options downloads'}, [
this.downloadPNG,
this.downloadSVG,
]);
}
buildEditor(container) {
const value = this.loadCode() || this.defaultCode;
const code = makeNode('textarea', {'class': 'editor-simple'});
code.value = value;
container.appendChild(code);
return code;
}
registerListeners() {
this.code.addEventListener('input', () => this.update(false));
this.diagram.addEventListener('render', () => {
this.updateMinSize(this.diagram.getSize());
this.pngDirty = true;
});
this.diagram.addEventListener('mouseover', (element) => {
if(this.marker) {
this.marker.clear();
}
if(element.ln !== undefined && this.code.markText) {
this.marker = this.code.markText(
{line: element.ln, ch: 0},
{line: element.ln + 1, ch: 0},
{
className: 'hover',
inclusiveLeft: false,
inclusiveRight: false,
clearOnEnter: true,
}
);
}
});
this.diagram.addEventListener('mouseout', () => {
if(this.marker) {
this.marker.clear();
this.marker = null;
}
});
this.diagram.addEventListener('click', (element) => {
if(this.marker) {
this.marker.clear();
this.marker = null;
}
if(element.ln !== undefined && this.code.setSelection) {
this.code.setSelection(
{line: element.ln, ch: 0},
{line: element.ln + 1, ch: 0},
{origin: '+focus', bias: -1}
);
this.code.focus();
}
});
this.diagram.addEventListener('dblclick', (element) => {
this.diagram.toggleCollapsed(element.ln);
});
this.container.addEventListener('dragover', (event) => {
event.preventDefault();
if(hasDroppedFile(event, 'image/svg+xml')) {
event.dataTransfer.dropEffect = 'copy';
this._showDropStyle();
} else {
event.dataTransfer.dropEffect = 'none';
}
});
this.container.addEventListener('dragleave', this._hideDropStyle);
this.container.addEventListener('dragend', this._hideDropStyle);
this.container.addEventListener('drop', (event) => {
event.preventDefault();
this._hideDropStyle();
const file = getDroppedFile(event, 'image/svg+xml');
if(file) {
this.loadFile(file);
}
});
return dom.el('div').setClass('options downloads')
.add(this.downloadPNG, this.downloadSVG);
}
buildLibrary(container) {
const diagrams = this.library.map((lib) => {
const holdInner = makeNode('div', {
'title': lib.title || lib.code,
});
const hold = makeNode('div', {
'class': 'library-item',
}, [holdInner]);
hold.addEventListener(
'click',
this.addCodeBlock.bind(this, lib.code)
);
container.appendChild(hold);
const diagram = this.diagram.clone({
const holdInner = dom.el('div')
.attr('title', lib.title || lib.code);
const hold = dom.el('div')
.setClass('library-item')
.add(holdInner)
.on('click', this.addCodeBlock.bind(this, lib.code))
.attach(container);
return this.diagram.clone({
code: simplifyPreview(lib.preview || lib.code),
container: holdInner,
container: holdInner.element,
render: false,
});
diagram.addEventListener('error', (sd, e) => {
}).on('error', (sd, e) => {
window.console.warn('Failed to render preview', e);
hold.setAttribute('class', 'library-item broken');
holdInner.textContent = lib.code;
hold.attr('class', 'library-item broken');
holdInner.text(lib.code);
});
return diagram;
});
try {
this.diagram.renderAll(diagrams);
} catch(e) {}
}
buildErrorReport() {
this.errorText = makeText();
this.errorMsg = makeNode('div', {'class': 'msg-error'}, [
this.errorText,
]);
return this.errorMsg;
return container;
}
buildViewPane() {
this.viewPaneInner = makeNode('div', {'class': 'pane-view-inner'});
this.viewPaneInner = dom.el('div').setClass('pane-view-inner')
.add(this.diagram.dom());
return makeNode('div', {'class': 'pane-view'}, [
makeNode('div', {'class': 'pane-view-scroller'}, [
this.viewPaneInner,
]),
this.buildErrorReport(),
]);
this.errorMsg = dom.el('div').setClass('msg-error');
return dom.el('div').setClass('pane-view')
.add(
dom.el('div').setClass('pane-view-scroller')
.add(this.viewPaneInner),
this.errorMsg
);
}
buildLeftPanes(container) {
const codePane = makeNode('div', {'class': 'pane-code'});
container.appendChild(codePane);
let libPane = null;
buildLeftPanes() {
const container = dom.el('div').setClass('pane-side');
this.code = dom.el('textarea')
.setClass('editor-simple')
.val(this.loadCode() || this.defaultCode)
.on('input', () => this.update(false));
const codePane = dom.el('div').setClass('pane-code')
.add(this.code)
.attach(container);
if(this.library.length > 0) {
const libPaneInner = makeNode('div', {
'class': 'pane-library-inner',
});
libPane = makeNode('div', {'class': 'pane-library'}, [
makeNode('div', {'class': 'pane-library-scroller'}, [
libPaneInner,
]),
]);
container.appendChild(libPane);
this.buildLibrary(libPaneInner);
const libPane = dom.el('div').setClass('pane-library')
.add(dom.el('div').setClass('pane-library-scroller')
.add(this.buildLibrary(
dom.el('div').setClass('pane-library-inner')
))
)
.attach(container);
makeSplit([codePane, libPane], {
makeSplit([codePane.element, libPane.element], {
direction: 'vertical',
snapOffset: 5,
sizes: [70, 30],
@ -363,39 +288,58 @@ define(['require'], (require) => {
});
}
return {codePane, libPane};
return container;
}
build(container) {
this.container = container;
const lPane = this.buildLeftPanes();
const viewPane = this.buildViewPane();
const lPane = makeNode('div', {'class': 'pane-side'});
const hold = makeNode('div', {'class': 'pane-hold'}, [
lPane,
viewPane,
this.buildOptionsLinks(),
this.buildOptionsDownloads(),
]);
container.appendChild(hold);
this.container = dom.wrap(container)
.add(dom.el('div').setClass('pane-hold')
.add(
lPane,
viewPane,
dom.el('div').setClass('options links')
.add(this.links.map((link) => dom.el('a')
.attrs({'href': link.href, 'target': '_blank'})
.text(link.label)
)),
this.buildOptionsDownloads()
)
)
.on('dragover', (event) => {
event.preventDefault();
if(hasDroppedFile(event, 'image/svg+xml')) {
event.dataTransfer.dropEffect = 'copy';
this._showDropStyle();
} else {
event.dataTransfer.dropEffect = 'none';
}
})
.on('dragleave', this._hideDropStyle)
.on('dragend', this._hideDropStyle)
.on('drop', (event) => {
event.preventDefault();
this._hideDropStyle();
const file = getDroppedFile(event, 'image/svg+xml');
if(file) {
this.loadFile(file);
}
});
makeSplit([lPane, viewPane], {
makeSplit([lPane.element, viewPane.element], {
direction: 'horizontal',
snapOffset: 70,
sizes: [30, 70],
minSize: [10, 10],
});
const {codePane} = this.buildLeftPanes(lPane);
this.code = this.buildEditor(codePane);
this.viewPaneInner.appendChild(this.diagram.dom());
this.registerListeners();
// Delay first update 1 frame to ensure render target is ready
// (prevents initial incorrect font calculations for custom fonts)
setTimeout(this.update.bind(this), 0);
this._enhanceEditor();
}
addCodeBlock(block) {
@ -413,15 +357,15 @@ define(['require'], (require) => {
this.code.setCursor({line: pos.line + lines, ch: 0});
} else {
const value = this.value();
const cur = this.code.selectionStart;
const cur = this.code.element.selectionStart;
const pos = ('\n' + value + '\n').indexOf('\n', cur);
const replaced = (
addNewline(value.substr(0, pos)) +
addNewline(block)
);
this.code.value = replaced + value.substr(pos);
this.code.selectionStart = replaced.length;
this.code.selectionEnd = replaced.length;
this.code
.val(replaced + value.substr(pos))
.select(replaced.length);
this.update(false);
}
@ -429,9 +373,10 @@ define(['require'], (require) => {
}
updateMinSize({width, height}) {
const style = this.viewPaneInner.style;
style.minWidth = Math.ceil(width * this.minScale) + 'px';
style.minHeight = Math.ceil(height * this.minScale) + 'px';
this.viewPaneInner.styles({
'minWidth': Math.ceil(width * this.minScale) + 'px',
'minHeight': Math.ceil(height * this.minScale) + 'px',
});
}
redraw(sequence) {
@ -465,23 +410,22 @@ define(['require'], (require) => {
markError(error) {
if(typeof error === 'object' && error.message) {
this.errorText.nodeValue = error.message;
this.errorMsg.text(error.message);
} else {
this.errorText.nodeValue = error;
this.errorMsg.text(error);
}
this.errorMsg.setAttribute('class', 'msg-error error');
this.errorMsg.addClass('error');
}
markOK() {
this.errorText.nodeValue = '';
this.errorMsg.setAttribute('class', 'msg-error');
this.errorMsg.text('').delClass('error');
}
value() {
if(this.code.getDoc) {
return this.code.getDoc().getValue();
} else {
return this.code.value;
return this.code.element.value;
}
}
@ -491,7 +435,7 @@ define(['require'], (require) => {
doc.setValue(code);
doc.clearHistory();
} else {
this.code.value = code;
this.code.val(code);
}
this.diagram.expandAll({render: false});
this.update(true);
@ -555,7 +499,7 @@ define(['require'], (require) => {
this.diagram.getPNG({resolution: PNG_RESOLUTION})
.then(({url, latest}) => {
if(latest) {
this.downloadPNG.setAttribute('href', url);
this.downloadPNG.attr('href', url);
this.updatingPNG = false;
}
});
@ -563,11 +507,11 @@ define(['require'], (require) => {
}
_showDropStyle() {
this.container.setAttribute('class', 'drop-target');
this.container.addClass('drop-target');
}
_hideDropStyle() {
this.container.setAttribute('class', '');
this.container.delClass('drop-target');
}
_downloadPNGFocus() {
@ -585,7 +529,7 @@ define(['require'], (require) => {
_downloadSVGClick() {
this.forceRender();
const url = this.diagram.getSVGSynchronous();
this.downloadSVG.setAttribute('href', url);
this.downloadSVG.attr('href', url);
}
_enhanceEditor() {
@ -599,12 +543,12 @@ define(['require'], (require) => {
], (CodeMirror) => {
this.diagram.registerCodeMirrorMode(CodeMirror);
const selBegin = this.code.selectionStart;
const selEnd = this.code.selectionEnd;
const value = this.code.value;
const focussed = this.code === document.activeElement;
const selBegin = this.code.element.selectionStart;
const selEnd = this.code.element.selectionEnd;
const value = this.code.element.value;
const focussed = this.code.focussed();
const code = new CodeMirror(this.code.parentNode, {
const code = new CodeMirror(this.code.element.parentNode, {
value,
mode: 'sequence',
globals: {
@ -622,7 +566,7 @@ define(['require'], (require) => {
'Cmd-Enter': 'autocomplete',
},
});
this.code.parentNode.removeChild(this.code);
this.code.detach();
code.getDoc().setSelection(
findPos(value, selBegin),
findPos(value, selEnd)

View File

@ -17,7 +17,7 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
'getSize',
'process',
'getThemeNames',
'addEventListener',
'on',
'registerCodeMirrorMode',
'getSVGSynchronous',
]);
@ -26,10 +26,11 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
agents: [],
stages: [],
});
sequenceDiagram.on.and.returnValue(sequenceDiagram);
sequenceDiagram.getSize.and.returnValue({width: 10, height: 20});
sequenceDiagram.dom.and.returnValue(document.createElement('svg'));
container = jasmine.createSpyObj('container', [
'appendChild',
'insertBefore',
'addEventListener',
]);
@ -42,7 +43,7 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
describe('build', () => {
it('adds elements to the given container', () => {
ui.build(container);
expect(container.appendChild).toHaveBeenCalled();
expect(container.insertBefore).toHaveBeenCalled();
});
it('creates a code mirror instance with the given code', (done) => {
@ -64,14 +65,16 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
sequenceDiagram.getSVGSynchronous.and.returnValue('mySVGURL');
ui.build(container);
expect(ui.downloadSVG.getAttribute('href')).toEqual('#');
const el = ui.downloadSVG.element;
expect(el.getAttribute('href')).toEqual('#');
if(safari) {
// Safari actually starts a download if we do this, which
// doesn't seem to fit its usual security vibe
return;
}
ui.downloadSVG.dispatchEvent(new Event('click'));
expect(ui.downloadSVG.getAttribute('href')).toEqual('mySVGURL');
el.dispatchEvent(new Event('click'));
expect(el.getAttribute('href')).toEqual('mySVGURL');
});
});
});

View File

@ -16,7 +16,7 @@ define(() => {
}
getSVGContent(renderer) {
let code = renderer.svg().outerHTML;
let code = renderer.dom().outerHTML;
// Firefox fails to render SVGs as <img> unless they have size
// attributes on the <svg> tag, so we must set this when

View File

@ -1,11 +1,11 @@
defineDescribe('Markdown Parser', [
'./MarkdownParser',
'svg/SVGTextBlock',
'svg/SVGUtilities',
'svg/SVG',
'stubs/TestDOM',
], (
parser,
SVGTextBlock,
svg
SVG,
TestDOM
) => {
'use strict';
@ -122,25 +122,12 @@ defineDescribe('Markdown Parser', [
]]);
});
describe('SVGTextBlock interaction', () => {
let hold = null;
let block = null;
beforeEach(() => {
hold = svg.makeContainer();
document.body.appendChild(hold);
block = new SVGTextBlock(hold, {attrs: {'font-size': 12}});
});
afterEach(() => {
document.body.removeChild(hold);
});
it('produces a format compatible with SVGTextBlock', () => {
const formatted = parser('hello everybody');
block.set({formatted});
expect(hold.children.length).toEqual(1);
expect(hold.children[0].innerHTML).toEqual('hello everybody');
});
it('produces a format compatible with SVG.formattedText', () => {
const formatted = parser('hello everybody');
const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
const block = svg.formattedText({}, formatted).element;
expect(block.outerHTML).toEqual(
'<g><text x="0" y="1">hello everybody</text></g>'
);
});
});

View File

@ -2,8 +2,8 @@
define([
'core/ArrayUtilities',
'core/EventObject',
'svg/SVGUtilities',
'svg/SVGShapes',
'core/DOMWrapper',
'svg/SVG',
'./components/BaseComponent',
'./components/Block',
'./components/Parallel',
@ -16,8 +16,8 @@ define([
], (
array,
EventObject,
svg,
SVGShapes,
DOMWrapper,
SVG,
BaseComponent
) => {
/* jshint +W072 */
@ -68,7 +68,8 @@ define([
themes = [],
namespace = null,
components = null,
SVGTextBlockClass = SVGShapes.TextBlock,
document,
textSizerFactory = null,
} = {}) {
super();
@ -82,10 +83,11 @@ define([
this.width = 0;
this.height = 0;
this.themes = makeThemes(themes);
this.themeBuilder = null;
this.theme = null;
this.namespace = parseNamespace(namespace);
this.components = components;
this.SVGTextBlockClass = SVGTextBlockClass;
this.svg = new SVG(new DOMWrapper(document), textSizerFactory);
this.knownThemeDefs = new Set();
this.knownDefs = new Set();
this.highlights = new Map();
@ -110,61 +112,54 @@ define([
this.themes.set(theme.name, theme);
}
buildMetadata() {
this.metaCode = svg.makeText();
return svg.make('metadata', {}, [this.metaCode]);
}
buildStaticElements() {
this.base = svg.makeContainer();
const el = this.svg.el;
this.themeDefs = svg.make('defs');
this.defs = svg.make('defs');
this.fullMask = svg.make('mask', {
this.metaCode = this.svg.txt();
this.themeDefs = el('defs');
this.defs = el('defs');
this.fullMask = el('mask').attrs({
'id': this.namespace + 'FullMask',
'maskUnits': 'userSpaceOnUse',
});
this.lineMask = svg.make('mask', {
this.lineMask = el('mask').attrs({
'id': this.namespace + 'LineMask',
'maskUnits': 'userSpaceOnUse',
});
this.fullMaskReveal = svg.make('rect', {'fill': '#FFFFFF'});
this.lineMaskReveal = svg.make('rect', {'fill': '#FFFFFF'});
this.backgroundFills = svg.make('g');
this.agentLines = svg.make('g', {
'mask': 'url(#' + this.namespace + 'LineMask)',
});
this.blocks = svg.make('g');
this.shapes = svg.make('g');
this.unmaskedShapes = svg.make('g');
this.base.appendChild(this.buildMetadata());
this.base.appendChild(this.themeDefs);
this.base.appendChild(this.defs);
this.base.appendChild(this.backgroundFills);
this.base.appendChild(
svg.make('g', {
'mask': 'url(#' + this.namespace + 'FullMask)',
}, [
this.agentLines,
this.blocks,
this.shapes,
])
);
this.base.appendChild(this.unmaskedShapes);
this.title = new this.SVGTextBlockClass(this.base);
this.fullMaskReveal = el('rect').attr('fill', '#FFFFFF');
this.lineMaskReveal = el('rect').attr('fill', '#FFFFFF');
this.backgroundFills = el('g');
this.agentLines = el('g')
.attr('mask', 'url(#' + this.namespace + 'LineMask)');
this.blocks = el('g');
this.shapes = el('g');
this.unmaskedShapes = el('g');
this.title = this.svg.formattedText();
this.sizer = new this.SVGTextBlockClass.SizeTester(this.base);
this.svg.body.add(
this.svg.el('metadata')
.add(this.metaCode),
this.themeDefs,
this.defs,
this.backgroundFills,
el('g')
.attr('mask', 'url(#' + this.namespace + 'FullMask)')
.add(
this.agentLines,
this.blocks,
this.shapes
),
this.unmaskedShapes,
this.title
);
}
addThemeDef(name, generator) {
const namespacedName = this.namespace + name;
if(this.knownThemeDefs.has(name)) {
return namespacedName;
if(!this.knownThemeDefs.has(name)) {
this.knownThemeDefs.add(name);
this.themeDefs.add(generator().attr('id', namespacedName));
}
this.knownThemeDefs.add(name);
const def = generator();
def.setAttribute('id', namespacedName);
this.themeDefs.appendChild(def);
return namespacedName;
}
@ -176,13 +171,10 @@ define([
}
const namespacedName = this.namespace + name;
if(this.knownDefs.has(name)) {
return namespacedName;
if(!this.knownDefs.has(name)) {
this.knownDefs.add(name);
this.defs.add(generator().attr('id', namespacedName));
}
this.knownDefs.add(name);
const def = generator();
def.setAttribute('id', namespacedName);
this.defs.appendChild(def);
return namespacedName;
}
@ -203,7 +195,7 @@ define([
renderer: this,
theme: this.theme,
agentInfos: this.agentInfos,
textSizer: this.sizer,
textSizer: this.svg.textSizer,
state: this.state,
components: this.components,
};
@ -251,7 +243,7 @@ define([
agentInfos: this.agentInfos,
visibleAgentIDs: this.visibleAgentIDs,
momentaryAgentIDs: agentIDs,
textSizer: this.sizer,
textSizer: this.svg.textSizer,
addSpacing,
addSeparation,
state: this.state,
@ -301,7 +293,7 @@ define([
renderer: this,
theme: this.theme,
agentInfos: this.agentInfos,
textSizer: this.sizer,
textSizer: this.svg.textSizer,
state: this.state,
components: this.components,
};
@ -346,20 +338,18 @@ define([
drawAgentLine(agentInfo, toY) {
if(
agentInfo.latestYStart === null ||
toY <= agentInfo.latestYStart
agentInfo.latestYStart !== null &&
toY > agentInfo.latestYStart
) {
return;
this.agentLines.add(this.theme.renderAgentLine({
x: agentInfo.x,
y0: agentInfo.latestYStart,
y1: toY,
width: agentInfo.currentRad * 2,
className: 'agent-' + agentInfo.index + '-line',
options: agentInfo.options,
}));
}
this.agentLines.appendChild(this.theme.renderAgentLine({
x: agentInfo.x,
y0: agentInfo.latestYStart,
y1: toY,
width: agentInfo.currentRad * 2,
className: 'agent-' + agentInfo.index + '-line',
options: agentInfo.options,
}));
}
addHighlightObject(line, o) {
@ -372,7 +362,7 @@ define([
}
forwardEvent(source, sourceEvent, forwardEvent, forwardArgs) {
source.addEventListener(
source.on(
sourceEvent,
this.trigger.bind(this, forwardEvent, forwardArgs)
);
@ -388,7 +378,7 @@ define([
renderer: this,
theme: this.theme,
agentInfos: this.agentInfos,
textSizer: this.sizer,
textSizer: this.svg.textSizer,
state: this.state,
components: this.components,
};
@ -403,16 +393,14 @@ define([
stageOverride = null,
unmasked = false,
} = {}) => {
const o = svg.make('g');
const o = this.svg.el('g').setClass('region');
const targetStage = (stageOverride || stage);
this.addHighlightObject(targetStage.ln, o);
o.setAttribute('class', 'region');
this.forwardEvent(o, 'mouseenter', 'mouseover', [targetStage]);
this.forwardEvent(o, 'mouseleave', 'mouseout', [targetStage]);
this.forwardEvent(o, 'click', 'click', [targetStage]);
this.forwardEvent(o, 'dblclick', 'dblclick', [targetStage]);
(unmasked ? this.unmaskedShapes : this.shapes).appendChild(o);
return o;
return o.attach(unmasked ? this.unmaskedShapes : this.shapes);
};
const env = {
@ -425,8 +413,7 @@ define([
lineMaskLayer: this.lineMask,
theme: this.theme,
agentInfos: this.agentInfos,
textSizer: this.sizer,
SVGTextBlockClass: this.SVGTextBlockClass,
textSizer: this.svg.textSizer,
state: this.state,
drawAgentLine: (agentID, toY, andStop = false) => {
const agentInfo = this.agentInfos.get(agentID);
@ -436,6 +423,7 @@ define([
addDef: this.addDef,
makeRegion,
components: this.components,
svg: this.svg,
};
let bottomY = topY;
@ -511,7 +499,7 @@ define([
updateBounds(stagesHeight) {
const cx = (this.minX + this.maxX) / 2;
const titleSize = this.sizer.measure(this.title);
const titleSize = this.svg.textSizer.measure(this.title);
const titleY = ((titleSize.height > 0) ?
(-this.theme.titleMargin - titleSize.height) : 0
);
@ -534,10 +522,10 @@ define([
'height': this.height,
};
svg.setAttributes(this.fullMaskReveal, fullSize);
svg.setAttributes(this.lineMaskReveal, fullSize);
this.fullMaskReveal.attrs(fullSize);
this.lineMaskReveal.attrs(fullSize);
this.base.setAttribute('viewBox', (
this.svg.body.attr('viewBox', (
x0 + ' ' + y0 + ' ' +
this.width + ' ' + this.height
));
@ -554,23 +542,23 @@ define([
_reset(theme) {
if(theme) {
this.knownThemeDefs.clear();
svg.empty(this.themeDefs);
this.themeDefs.empty();
}
this.knownDefs.clear();
this.highlights.clear();
svg.empty(this.defs);
svg.empty(this.fullMask);
svg.empty(this.lineMask);
svg.empty(this.backgroundFills);
svg.empty(this.agentLines);
svg.empty(this.blocks);
svg.empty(this.shapes);
svg.empty(this.unmaskedShapes);
this.fullMask.appendChild(this.fullMaskReveal);
this.lineMask.appendChild(this.lineMaskReveal);
this.defs.appendChild(this.fullMask);
this.defs.appendChild(this.lineMask);
this.defs.empty();
this.fullMask.empty();
this.lineMask.empty();
this.backgroundFills.empty();
this.agentLines.empty();
this.blocks.empty();
this.shapes.empty();
this.unmaskedShapes.empty();
this.defs.add(
this.fullMask.add(this.fullMaskReveal),
this.lineMask.add(this.lineMaskReveal)
);
this._resetState();
}
@ -583,12 +571,12 @@ define([
}
if(this.highlights.has(this.currentHighlight)) {
this.highlights.get(this.currentHighlight).forEach((o) => {
o.setAttribute('class', 'region');
o.delClass('focus');
});
}
if(this.highlights.has(line)) {
this.highlights.get(line).forEach((o) => {
o.setAttribute('class', 'region focus');
o.addClass('focus');
});
}
this.currentHighlight = line;
@ -638,11 +626,14 @@ define([
}
_switchTheme(name) {
const oldTheme = this.theme;
this.theme = this.getThemeNamed(name);
const oldThemeBuilder = this.themeBuilder;
this.themeBuilder = this.getThemeNamed(name);
if(this.themeBuilder !== oldThemeBuilder) {
this.theme = this.themeBuilder.build(this.svg);
}
this.theme.reset();
return (this.theme !== oldTheme);
return (this.themeBuilder !== oldThemeBuilder);
}
optimisedRenderPreReflow(sequence) {
@ -656,7 +647,7 @@ define([
attrs: this.theme.titleAttrs,
formatted: sequence.meta.title,
});
this.sizer.expectMeasure(this.title);
this.svg.textSizer.expectMeasure(this.title);
this.minX = 0;
this.maxX = 0;
@ -665,11 +656,11 @@ define([
sequence.stages.forEach(this.prepareMeasurementsStage);
this._resetState();
this.sizer.performMeasurementsPre();
this.svg.textSizer.performMeasurementsPre();
}
optimisedRenderReflow() {
this.sizer.performMeasurementsAct();
this.svg.textSizer.performMeasurementsAct();
}
optimisedRenderPostReflow(sequence) {
@ -689,8 +680,8 @@ define([
this.currentHighlight = -1;
this.setHighlight(prevHighlight);
this.sizer.performMeasurementsPost();
this.sizer.resetCache();
this.svg.textSizer.performMeasurementsPost();
this.svg.textSizer.resetCache();
}
render(sequence) {
@ -721,8 +712,8 @@ define([
return this.agentInfos.get(id).x;
}
svg() {
return this.base;
dom() {
return this.svg.body.element;
}
};
});

View File

@ -10,17 +10,20 @@ defineDescribe('Sequence Renderer', [
let renderer = null;
beforeEach(() => {
renderer = new Renderer({themes: [new BasicTheme()]});
document.body.appendChild(renderer.svg());
renderer = new Renderer({
themes: [new BasicTheme.Factory()],
document: window.document,
});
document.body.appendChild(renderer.dom());
});
afterEach(() => {
document.body.removeChild(renderer.svg());
document.body.removeChild(renderer.dom());
});
describe('.svg', () => {
describe('.dom', () => {
it('returns an SVG node containing the rendered diagram', () => {
const svg = renderer.svg();
const svg = renderer.dom();
expect(svg.tagName).toEqual('svg');
});
});
@ -76,7 +79,7 @@ defineDescribe('Sequence Renderer', [
],
stages: [],
});
const element = renderer.svg();
const element = renderer.dom();
const title = element.getElementsByClassName('title')[0];
expect(title.innerHTML).toEqual('Title');
});
@ -99,7 +102,7 @@ defineDescribe('Sequence Renderer', [
],
stages: [],
});
const element = renderer.svg();
const element = renderer.dom();
const metadata = element.getElementsByTagName('metadata')[0];
expect(metadata.innerHTML).toEqual('hello');
});
@ -141,7 +144,7 @@ defineDescribe('Sequence Renderer', [
],
});
const element = renderer.svg();
const element = renderer.dom();
const line = element.getElementsByClassName('agent-1-line')[0];
const drawnX = Number(line.getAttribute('x1'));

View File

@ -28,11 +28,11 @@ define([
'use strict';
const themes = [
new BasicTheme(),
new MonospaceTheme(),
new ChunkyTheme(),
new SketchTheme(SketchTheme.RIGHT),
new SketchTheme(SketchTheme.LEFT),
new BasicTheme.Factory(),
new MonospaceTheme.Factory(),
new ChunkyTheme.Factory(),
new SketchTheme.Factory(SketchTheme.RIGHT),
new SketchTheme.Factory(SketchTheme.LEFT),
];
const SharedParser = new Parser();
@ -83,6 +83,14 @@ define([
}
}
function pickDocument(container) {
if(container) {
return container.ownerDocument;
} else {
return window.document;
}
}
class SequenceDiagram extends EventObject {
constructor(code = null, options = {}) {
super();
@ -92,16 +100,24 @@ define([
code = options.code;
}
this.registerCodeMirrorMode = registerCodeMirrorMode;
Object.assign(this, {
code,
latestProcessed: null,
isInteractive: false,
textSizerFactory: options.textSizerFactory || null,
registerCodeMirrorMode,
parser: SharedParser,
generator: SharedGenerator,
renderer: new Renderer(Object.assign({
themes,
document: pickDocument(options.container),
}, options)),
exporter: new Exporter(),
});
this.code = code;
this.parser = SharedParser;
this.generator = SharedGenerator;
this.renderer = new Renderer(Object.assign({themes}, options));
this.exporter = new Exporter();
this.renderer.addEventForwarding(this);
this.latestProcessed = null;
this.isInteractive = false;
if(options.container) {
options.container.appendChild(this.dom());
}
@ -114,6 +130,8 @@ define([
}
clone(options = {}) {
const reference = (options.container || this.renderer.dom());
return new SequenceDiagram(Object.assign({
code: this.code,
container: null,
@ -121,7 +139,8 @@ define([
namespace: null,
components: this.renderer.components,
interactive: this.isInteractive,
SVGTextBlockClass: this.renderer.SVGTextBlockClass,
document: reference.ownerDocument,
textSizerFactory: this.textSizerFactory,
}, options));
}
@ -232,9 +251,9 @@ define([
}
_revertParent(state) {
const dom = this.renderer.svg();
const dom = this.renderer.dom();
if(dom.parentNode !== state.originalParent) {
document.body.removeChild(dom);
dom.parentNode.removeChild(dom);
if(state.originalParent) {
state.originalParent.appendChild(dom);
}
@ -248,7 +267,7 @@ define([
}
optimisedRenderPreReflow(processed = null) {
const dom = this.renderer.svg();
const dom = this.renderer.dom();
this.renderState = {
originalParent: dom.parentNode,
processed,
@ -256,11 +275,11 @@ define([
};
const state = this.renderState;
if(!document.body.contains(dom)) {
if(!dom.isConnected) {
if(state.originalParent) {
state.originalParent.removeChild(dom);
}
document.body.appendChild(dom);
dom.ownerDocument.body.appendChild(dom);
}
try {
@ -350,7 +369,7 @@ define([
}
dom() {
return this.renderer.svg();
return this.renderer.dom();
}
}
@ -381,8 +400,6 @@ define([
Object.assign(tagOptions, options)
);
const newElement = diagram.dom();
element.parentNode.insertBefore(newElement, element);
element.parentNode.removeChild(element);
const attrs = element.attributes;
for(let i = 0; i < attrs.length; ++ i) {
newElement.setAttribute(
@ -390,6 +407,7 @@ define([
attrs[i].nodeValue
);
}
element.parentNode.replaceChild(newElement, element);
return diagram;
}

View File

@ -5,14 +5,14 @@ defineDescribe('SequenceDiagram', [
'./Generator',
'./Renderer',
'./Exporter',
'stubs/SVGTextBlock',
'stubs/TestDOM',
], (
SequenceDiagram,
Parser,
Generator,
Renderer,
Exporter,
SVGTextBlock
TestDOM
) => {
/* jshint +W072 */
'use strict';
@ -30,7 +30,7 @@ defineDescribe('SequenceDiagram', [
beforeEach(() => {
diagram = new SequenceDiagram({
namespace: '',
SVGTextBlockClass: SVGTextBlock,
textSizerFactory: TestDOM.textSizerFactory,
});
});
@ -87,6 +87,7 @@ defineDescribe('SequenceDiagram', [
'<g mask="url(#FullMask)">' +
'<g mask="url(#LineMask)"></g>' +
'</g>' +
'<g>' +
'<text' +
' x="0"' +
' font-family="sans-serif"' +
@ -94,7 +95,8 @@ defineDescribe('SequenceDiagram', [
' line-height="1.3"' +
' text-anchor="middle"' +
' class="title"' +
' y="-11">My title here</text>' +
' y="-10">My title here</text>' +
'</g>' +
'</svg>'
);
});
@ -110,10 +112,12 @@ defineDescribe('SequenceDiagram', [
// Agent 1
expect(content).toContain(
'<line x1="20.5" y1="11" x2="20.5" y2="46" class="agent-1-line"'
'<line fill="none" stroke="#000000" stroke-width="1"' +
' x1="20.5" y1="11" x2="20.5" y2="46" class="agent-1-line"'
);
expect(content).toContain(
'<rect x="10" y="0" width="21" height="11"'
'<rect fill="transparent" class="outline"' +
' x="10" y="0" width="21" height="11"'
);
expect(content).toContain(
'<text x="20.5"'
@ -121,10 +125,12 @@ defineDescribe('SequenceDiagram', [
// Agent 2
expect(content).toContain(
'<line x1="51.5" y1="11" x2="51.5" y2="46" class="agent-2-line"'
'<line fill="none" stroke="#000000" stroke-width="1"' +
' x1="51.5" y1="11" x2="51.5" y2="46" class="agent-2-line"'
);
expect(content).toContain(
'<rect x="41" y="0" width="21" height="11"'
'<rect fill="transparent" class="outline"' +
' x="41" y="0" width="21" height="11"'
);
expect(content).toContain(
'<text x="51.5"'
@ -147,9 +153,19 @@ defineDescribe('SequenceDiagram', [
expect(content).toContain('<svg viewBox="-5 -5 60 39">');
expect(content).toContain('<line x1="20" y1="7" x2="20" y2="29"');
expect(content).toContain('<line x1="30" y1="7" x2="30" y2="29"');
expect(content).toContain('<rect x="10" y="0" width="30" height="9"');
expect(content).toContain(
'<line fill="none" stroke="#000000" stroke-width="1"' +
' x1="20" y1="7" x2="20" y2="29"'
);
expect(content).toContain(
'<line fill="none" stroke="#000000" stroke-width="1"' +
' x1="30" y1="7" x2="30" y2="29"'
);
expect(content).toContain(
'<rect fill="none" stroke="#000000" stroke-width="1.5"' +
' rx="2" ry="2"' +
' x="10" y="0" width="30" height="9"'
);
expect(content).toContain('<g class="region collapsed"');
});

View File

@ -1,16 +1,17 @@
define([
'./BaseComponent',
'core/ArrayUtilities',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseComponent,
array,
svg,
SVGShapes
array
) => {
'use strict';
const OUTLINE_ATTRS = {
'fill': 'transparent',
'class': 'outline',
};
class CapBox {
getConfig(options, env) {
let config = null;
@ -52,27 +53,18 @@ define([
render(y, {x, formattedLabel, options}, env) {
const config = this.getConfig(options, env);
const clickable = env.makeRegion();
const text = SVGShapes.renderBoxedText(formattedLabel, {
x,
y,
padding: config.padding,
boxAttrs: config.boxAttrs,
boxRenderer: config.boxRenderer,
labelAttrs: config.labelAttrs,
boxLayer: clickable,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
});
clickable.insertBefore(svg.make('rect', {
'x': x - text.width / 2,
'y': y,
'width': text.width,
'height': text.height,
'fill': 'transparent',
'class': 'outline',
}), text.label.firstLine());
const text = env.svg.boxedText(config, formattedLabel, {x, y});
env.makeRegion().add(
text,
env.svg.box(OUTLINE_ATTRS, {
'x': x - text.width / 2,
'y': y,
'width': text.width,
'height': text.height,
})
);
return {
lineTop: 0,
@ -104,22 +96,20 @@ define([
const config = env.theme.agentCap.cross;
const d = config.size / 2;
const clickable = env.makeRegion();
clickable.appendChild(config.render({
x,
y: y + d,
radius: d,
options,
}));
clickable.appendChild(svg.make('rect', {
'x': x - d,
'y': y,
'width': d * 2,
'height': d * 2,
'fill': 'transparent',
'class': 'outline',
}));
env.makeRegion().add(
config.render({
x,
y: y + d,
radius: d,
options,
}),
env.svg.box(OUTLINE_ATTRS, {
'x': x - d,
'y': y,
'width': d * 2,
'height': d * 2,
})
);
return {
lineTop: d,
@ -165,22 +155,21 @@ define([
);
const height = barCfg.height;
const clickable = env.makeRegion();
clickable.appendChild(barCfg.render({
x: x - width / 2,
y,
width,
height,
options,
}));
clickable.appendChild(svg.make('rect', {
'x': x - width / 2,
'y': y,
'width': width,
'height': height,
'fill': 'transparent',
'class': 'outline',
}));
env.makeRegion().add(
barCfg.render({
x: x - width / 2,
y,
width,
height,
options,
}),
env.svg.box(OUTLINE_ATTRS, {
'x': x - width / 2,
'y': y,
'width': width,
'height': height,
})
);
return {
lineTop: 0,
@ -212,38 +201,37 @@ define([
const ratio = config.height / (config.height + config.extend);
const gradID = env.addDef(isBegin ? 'FadeIn' : 'FadeOut', () => {
return svg.make('linearGradient', {
return env.svg.linearGradient({
'x1': '0%',
'y1': isBegin ? '100%' : '0%',
'x2': '0%',
'y2': isBegin ? '0%' : '100%',
}, [
svg.make('stop', {
{
'offset': '0%',
'stop-color': '#FFFFFF',
}),
svg.make('stop', {
},
{
'offset': (100 * ratio).toFixed(3) + '%',
'stop-color': '#000000',
}),
},
]);
});
env.lineMaskLayer.appendChild(svg.make('rect', {
env.lineMaskLayer.add(env.svg.box({
'fill': 'url(#' + gradID + ')',
}, {
'x': x - config.width / 2,
'y': y - (isBegin ? config.extend : 0),
'width': config.width,
'height': config.height + config.extend,
'fill': 'url(#' + gradID + ')',
}));
env.makeRegion().appendChild(svg.make('rect', {
env.makeRegion().add(env.svg.box(OUTLINE_ATTRS, {
'x': x - config.width / 2,
'y': y,
'width': config.width,
'height': config.height,
'fill': 'transparent',
'class': 'outline',
}));
return {
@ -275,13 +263,11 @@ define([
const config = env.theme.agentCap.none;
const w = 10;
env.makeRegion().appendChild(svg.make('rect', {
env.makeRegion().add(env.svg.box(OUTLINE_ATTRS, {
'x': x - w / 2,
'y': y,
'width': w,
'height': config.height,
'fill': 'transparent',
'class': 'outline',
}));
return {
@ -359,12 +345,7 @@ define([
const cap = AGENT_CAPS[mode];
const topShift = cap.topShift(agentInfo, env, this.begin);
const y0 = env.primaryY - topShift;
const shifts = cap.render(
y0,
agentInfo,
env,
this.begin
);
const shifts = cap.render(y0, agentInfo, env, this.begin);
maxEnd = Math.max(maxEnd, y0 + shifts.height);
if(this.begin) {
env.drawAgentLine(id, y0 + shifts.lineBottom);

View File

@ -66,8 +66,8 @@ define(() => {
blockLayer,
theme,
agentInfos,
svg,
textSizer,
SVGTextBlockClass,
addDef,
makeRegion,
state,

View File

@ -1,16 +1,17 @@
define([
'./BaseComponent',
'core/ArrayUtilities',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseComponent,
array,
svg,
SVGShapes
array
) => {
'use strict';
const OUTLINE_ATTRS = {
'fill': 'transparent',
'class': 'outline',
};
class BlockSplit extends BaseComponent {
prepareMeasurements({left, tag, label}, env) {
const blockInfo = env.state.blocks.get(left);
@ -53,58 +54,46 @@ define([
const clickable = env.makeRegion();
const tagRender = SVGShapes.renderBoxedText(tag, {
x: agentInfoL.x,
y,
const tagRender = env.svg.boxedText({
padding: config.section.tag.padding,
boxAttrs: config.section.tag.boxAttrs,
boxRenderer: config.section.tag.boxRenderer,
labelAttrs: config.section.tag.labelAttrs,
boxLayer: blockInfo.hold,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
});
}, tag, {x: agentInfoL.x, y});
const labelRender = SVGShapes.renderBoxedText(label, {
x: agentInfoL.x + tagRender.width,
y,
const labelRender = env.svg.boxedText({
padding: config.section.label.padding,
boxAttrs: {'fill': '#000000'},
labelAttrs: config.section.label.labelAttrs,
boxLayer: env.lineMaskLayer,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
});
}, label, {x: agentInfoL.x + tagRender.width, y});
const labelHeight = Math.max(
Math.max(tagRender.height, labelRender.height),
config.section.label.minHeight
);
clickable.insertBefore(svg.make('rect', {
'x': agentInfoL.x,
'y': y,
'width': agentInfoR.x - agentInfoL.x,
'height': labelHeight,
'fill': 'transparent',
'class': 'outline',
}), clickable.firstChild);
blockInfo.hold.add(tagRender.box);
env.lineMaskLayer.add(labelRender.box);
clickable.add(
env.svg.box(OUTLINE_ATTRS, {
'x': agentInfoL.x,
'y': y,
'width': agentInfoR.x - agentInfoL.x,
'height': labelHeight,
}),
tagRender.label,
labelRender.label
);
if(!first) {
blockInfo.hold.appendChild(config.sepRenderer({
blockInfo.hold.add(config.sepRenderer({
'x1': agentInfoL.x,
'y1': y,
'x2': agentInfoR.x,
'y2': y,
}));
} else if(blockInfo.canHide) {
clickable.setAttribute(
'class',
clickable.getAttribute('class') +
(blockInfo.hide ? ' collapsed' : ' expanded')
);
clickable.addClass(blockInfo.hide ? 'collapsed' : 'expanded');
}
return y + labelHeight + config.section.padding.top;
@ -160,8 +149,8 @@ define([
}
render(stage, env) {
const hold = svg.make('g');
env.blockLayer.appendChild(hold);
const hold = env.svg.el('g');
env.blockLayer.add(hold);
const blockInfo = env.state.blocks.get(stage.left);
blockInfo.hold = hold;
@ -215,13 +204,9 @@ define([
shapes = {shape: shapes};
}
blockInfo.hold.appendChild(shapes.shape);
if(shapes.fill) {
env.fillLayer.appendChild(shapes.fill);
}
if(shapes.mask) {
env.lineMaskLayer.appendChild(shapes.mask);
}
blockInfo.hold.add(shapes.shape);
env.fillLayer.add(shapes.fill);
env.lineMaskLayer.add(shapes.mask);
return env.primaryY + config.margin.bottom + env.theme.actionMargin;
}

View File

@ -1,16 +1,17 @@
define([
'core/ArrayUtilities',
'./BaseComponent',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
array,
BaseComponent,
svg,
SVGShapes
BaseComponent
) => {
'use strict';
const OUTLINE_ATTRS = {
'fill': 'transparent',
'class': 'outline',
};
class Arrowhead {
constructor(propName) {
this.propName = propName;
@ -38,7 +39,7 @@ define([
render(layer, theme, pt, dir) {
const config = this.getConfig(theme);
const short = this.short(theme);
layer.appendChild(config.render(config.attrs, {
layer.add(config.render(config.attrs, {
x: pt.x + short * dir.dx,
y: pt.y + short * dir.dy,
width: config.width,
@ -76,7 +77,7 @@ define([
render(layer, theme, pt, dir) {
const config = this.getConfig(theme);
layer.appendChild(config.render({
layer.add(config.render({
x: pt.x + config.short * dir.dx,
y: pt.y + config.short * dir.dy,
radius: config.radius,
@ -194,7 +195,7 @@ define([
xR,
rad: config.loopbackRadius,
});
clickable.appendChild(rendered.shape);
clickable.add(rendered.shape);
lArrow.render(clickable, env.theme, {
x: rendered.p1.x - dx1,
@ -227,19 +228,15 @@ define([
(label ? config.label.padding : 0)
);
const clickable = env.makeRegion();
const renderedText = SVGShapes.renderBoxedText(label, {
x: xL - config.mask.padding.left,
y: yBegin - height + config.label.margin.top,
const renderedText = env.svg.boxedText({
padding: config.mask.padding,
boxAttrs: {'fill': '#000000'},
labelAttrs: config.label.loopbackAttrs,
boxLayer: env.lineMaskLayer,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}, label, {
x: xL - config.mask.padding.left,
y: yBegin - height + config.label.margin.top,
});
const labelW = (label ? (
renderedText.width +
config.label.padding -
@ -252,6 +249,20 @@ define([
xL + labelW
);
const raise = Math.max(height, lArrow.height(env.theme) / 2);
const arrowDip = rArrow.height(env.theme) / 2;
env.lineMaskLayer.add(renderedText.box);
const clickable = env.makeRegion().add(
env.svg.box(OUTLINE_ATTRS, {
'x': from.x,
'y': yBegin - raise,
'width': xR + config.loopbackRadius - from.x,
'height': raise + env.primaryY - yBegin + arrowDip,
}),
renderedText.label
);
this.renderRevArrowLine({
x1: from.x + from.currentMaxRad,
y1: yBegin,
@ -260,18 +271,6 @@ define([
xR,
}, options, env, clickable);
const raise = Math.max(height, lArrow.height(env.theme) / 2);
const arrowDip = rArrow.height(env.theme) / 2;
clickable.insertBefore(svg.make('rect', {
'x': from.x,
'y': yBegin - raise,
'width': xR + config.loopbackRadius - from.x,
'height': raise + env.primaryY - yBegin + arrowDip,
'fill': 'transparent',
'class': 'outline',
}), clickable.firstChild);
return (
env.primaryY +
Math.max(arrowDip, 0) +
@ -300,7 +299,7 @@ define([
x2: x2 - d2 * dx,
y2: y2 - d2 * dy,
});
clickable.appendChild(rendered.shape);
clickable.add(rendered.shape);
const p1 = {x: rendered.p1.x - d1 * dx, y: rendered.p1.y - d1 * dy};
const p2 = {x: rendered.p2.x + d2 * dx, y: rendered.p2.y + d2 * dy};
@ -320,14 +319,14 @@ define([
const config = env.theme.connect.source;
if(from.isVirtualSource) {
clickable.appendChild(config.render({
clickable.add(config.render({
x: rendered.p1.x - config.radius,
y: rendered.p1.y,
radius: config.radius,
}));
}
if(to.isVirtualSource) {
clickable.appendChild(config.render({
clickable.add(config.render({
x: rendered.p2.x + config.radius,
y: rendered.p2.y,
radius: config.radius,
@ -352,21 +351,20 @@ define([
')'
);
boxAttrs.transform = transform;
labelLayer = svg.make('g', {'transform': transform});
layer.appendChild(labelLayer);
labelLayer = env.svg.el('g').attrs({'transform': transform});
layer.add(labelLayer);
}
SVGShapes.renderBoxedText(label, {
x: midX,
y: midY + config.label.margin.top - height,
const text = env.svg.boxedText({
padding: config.mask.padding,
boxAttrs,
labelAttrs: config.label.attrs,
boxLayer: env.lineMaskLayer,
labelLayer,
SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
}, label, {
x: midX,
y: midY + config.label.margin.top - height,
});
env.lineMaskLayer.add(text.box);
labelLayer.add(text.label);
}
renderSimpleConnect({label, agentIDs, options}, env, from, yBegin) {
@ -402,17 +400,16 @@ define([
this.renderVirtualSources({from, to, rendered}, env, clickable);
clickable.appendChild(svg.make('path', {
'd': (
clickable.add(env.svg.el('path')
.attrs(OUTLINE_ATTRS)
.attr('d', (
'M' + x1 + ',' + (yBegin - lift) +
'L' + x2 + ',' + (env.primaryY - lift) +
'L' + x2 + ',' + (env.primaryY + arrowSpread) +
'L' + x1 + ',' + (yBegin + arrowSpread) +
'Z'
),
'fill': 'transparent',
'class': 'outline',
}));
))
);
this.renderSimpleLabel(label, {
layer: clickable,

View File

@ -1,14 +1,15 @@
define([
'./BaseComponent',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseComponent,
svg,
SVGShapes
BaseComponent
) => {
'use strict';
const OUTLINE_ATTRS = {
'fill': 'transparent',
'class': 'outline',
};
class Divider extends BaseComponent {
prepareMeasurements({mode, formattedLabel}, env) {
const config = env.theme.getDivider(mode);
@ -41,8 +42,6 @@ define([
const left = env.agentInfos.get('[');
const right = env.agentInfos.get(']');
const clickable = env.makeRegion({unmasked: true});
let labelWidth = 0;
let labelHeight = 0;
if(formattedLabel) {
@ -54,22 +53,22 @@ define([
const fullHeight = Math.max(height, labelHeight) + config.margin;
let labelText = null;
if(formattedLabel) {
const boxed = SVGShapes.renderBoxedText(formattedLabel, {
const boxed = env.svg.boxedText({
padding: config.padding,
boxAttrs: {'fill': '#000000'},
labelAttrs: config.labelAttrs,
}, formattedLabel, {
x: (left.x + right.x) / 2,
y: (
env.primaryY +
(fullHeight - labelHeight) / 2 -
config.padding.top
),
padding: config.padding,
boxAttrs: {'fill': '#000000'},
labelAttrs: config.labelAttrs,
boxLayer: env.fullMaskLayer,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
textSizer: env.textSizer,
});
env.fullMaskLayer.add(boxed.box);
labelText = boxed.label;
labelWidth = boxed.width;
}
@ -82,20 +81,18 @@ define([
height,
env,
});
if(shape) {
clickable.insertBefore(shape, clickable.firstChild);
}
if(mask) {
env.fullMaskLayer.appendChild(mask);
}
clickable.insertBefore(svg.make('rect', {
'x': left.x - config.extend,
'y': env.primaryY,
'width': right.x - left.x + config.extend * 2,
'height': fullHeight,
'fill': 'transparent',
'class': 'outline',
}), clickable.firstChild);
env.fullMaskLayer.add(mask);
env.makeRegion({unmasked: true}).add(
env.svg.box(OUTLINE_ATTRS, {
'x': left.x - config.extend,
'y': env.primaryY,
'width': right.x - left.x + config.extend * 2,
'height': fullHeight,
}),
shape,
labelText
);
return env.primaryY + fullHeight + env.theme.actionMargin;
}

View File

@ -1,6 +1,11 @@
define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
define(['./BaseComponent'], (BaseComponent) => {
'use strict';
const OUTLINE_ATTRS = {
'fill': 'transparent',
'class': 'outline',
};
function findExtremes(agentInfos, agentIDs) {
let min = null;
let max = null;
@ -39,14 +44,8 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
}, env) {
const config = env.theme.getNote(mode);
const clickable = env.makeRegion();
const y = env.topY + config.margin.top + config.padding.top;
const labelNode = new env.SVGTextBlockClass(clickable, {
attrs: config.labelAttrs,
formatted: label,
y,
});
const labelNode = env.svg.formattedText(config.labelAttrs, label);
const size = env.textSizer.measure(labelNode);
const fullW = (
@ -85,21 +84,21 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
break;
}
clickable.insertBefore(svg.make('rect', {
'x': x0,
'y': env.topY + config.margin.top,
'width': x1 - x0,
'height': fullH,
'fill': 'transparent',
'class': 'outline',
}), clickable.firstChild);
clickable.insertBefore(config.boxRenderer({
x: x0,
y: env.topY + config.margin.top,
width: x1 - x0,
height: fullH,
}), clickable.firstChild);
env.makeRegion().add(
config.boxRenderer({
x: x0,
y: env.topY + config.margin.top,
width: x1 - x0,
height: fullH,
}),
env.svg.box(OUTLINE_ATTRS, {
'x': x0,
'y': env.topY + config.margin.top,
'width': x1 - x0,
'height': fullH,
}),
labelNode
);
return (
env.topY +

View File

@ -1,25 +1,6 @@
define([
'svg/SVGUtilities',
'svg/SVGShapes',
], (
svg,
SVGShapes
) => {
define(() => {
'use strict';
function deepCopy(o) {
if(typeof o !== 'object' || !o) {
return o;
}
const r = {};
for(const k in o) {
if(o.hasOwnProperty(k)) {
r[k] = deepCopy(o[k]);
}
}
return r;
}
function optionsAttributes(attributes, options) {
let attrs = Object.assign({}, attributes['']);
options.forEach((opt) => {
@ -29,14 +10,12 @@ define([
}
class BaseTheme {
constructor({name, settings, blocks, notes, dividers}) {
this.name = name;
this.blocks = deepCopy(blocks);
this.notes = deepCopy(notes);
this.dividers = deepCopy(dividers);
Object.assign(this, deepCopy(settings));
constructor(svg) {
this.svg = svg;
}
// PUBLIC API
reset() {
}
@ -62,116 +41,297 @@ define([
renderAgentLine({x, y0, y1, width, className, options}) {
const attrs = this.optionsAttributes(this.agentLineAttrs, options);
if(width > 0) {
return svg.make('rect', Object.assign({
return this.svg.box(attrs, {
'x': x - width / 2,
'y': y0,
'width': width,
'height': y1 - y0,
'class': className,
}, attrs));
}).addClass(className);
} else {
return svg.make('line', Object.assign({
return this.svg.line(attrs, {
'x1': x,
'y1': y0,
'x2': x,
'y2': y1,
'class': className,
}, attrs));
}).addClass(className);
}
}
}
BaseTheme.renderArrowHead = (attrs, {x, y, width, height, dir}) => {
const wx = width * dir.dx;
const wy = width * dir.dy;
const hy = height * 0.5 * dir.dx;
const hx = -height * 0.5 * dir.dy;
return svg.make(
attrs.fill === 'none' ? 'polyline' : 'polygon',
Object.assign({
'points': (
// INTERNAL HELPERS
renderArrowHead(attrs, {x, y, width, height, dir}) {
const wx = width * dir.dx;
const wy = width * dir.dy;
const hy = height * 0.5 * dir.dx;
const hx = -height * 0.5 * dir.dy;
return this.svg.el(attrs.fill === 'none' ? 'polyline' : 'polygon')
.attr('points', (
(x + wx - hx) + ' ' + (y + wy - hy) + ' ' +
x + ' ' + y + ' ' +
(x + wx + hx) + ' ' + (y + wy + hy)
),
}, attrs)
);
};
BaseTheme.renderTag = (attrs, {x, y, width, height}) => {
const {rx, ry} = attrs;
const x2 = x + width;
const y2 = y + height;
const line = (
'M' + x2 + ' ' + y +
'L' + x2 + ' ' + (y2 - ry) +
'L' + (x2 - rx) + ' ' + y2 +
'L' + x + ' ' + y2
);
const g = svg.make('g');
if(attrs.fill !== 'none') {
g.appendChild(svg.make('path', Object.assign({
'd': line + 'L' + x + ' ' + y,
}, attrs, {'stroke': 'none'})));
))
.attrs(attrs);
}
if(attrs.stroke !== 'none') {
g.appendChild(svg.make('path', Object.assign({
'd': line,
}, attrs, {'fill': 'none'})));
renderTag(attrs, {x, y, width, height}) {
const {rx, ry} = attrs;
const x2 = x + width;
const y2 = y + height;
const line = (
'M' + x2 + ' ' + y +
'L' + x2 + ' ' + (y2 - ry) +
'L' + (x2 - rx) + ' ' + y2 +
'L' + x + ' ' + y2
);
const g = this.svg.el('g');
if(attrs.fill !== 'none') {
g.add(this.svg.el('path')
.attr('d', line + 'L' + x + ' ' + y)
.attrs(attrs)
.attr('stroke', 'none')
);
}
if(attrs.stroke !== 'none') {
g.add(this.svg.el('path')
.attr('d', line)
.attrs(attrs)
.attr('fill', 'none')
);
}
return g;
}
return g;
};
renderDB(attrs, position) {
const z = attrs['db-z'];
return this.svg.el('g').add(
this.svg.box({
'rx': position.width / 2,
'ry': z,
}, position).attrs(attrs),
this.svg.el('path')
.attr('d', (
'M' + position.x + ' ' + (position.y + z) +
'a' + (position.width / 2) + ' ' + z +
' 0 0 0 ' + position.width + ' 0'
))
.attrs(attrs)
.attr('fill', 'none')
);
}
BaseTheme.renderDB = (attrs, {x, y, width, height}) => {
const z = attrs['db-z'];
return svg.make('g', {}, [
svg.make('rect', Object.assign({
'x': x,
'y': y,
'width': width,
'height': height,
'rx': width / 2,
'ry': z,
}, attrs)),
svg.make('path', Object.assign({
'd': (
'M' + x + ' ' + (y + z) +
'a' + (width / 2) + ' ' + z +
' 0 0 0 ' + width + ' 0'
),
}, attrs, {'fill': 'none'})),
]);
};
renderRef(options, position) {
return {
shape: this.svg.box(options, position).attrs({'fill': 'none'}),
mask: this.svg.box(options, position).attrs({
'fill': '#000000',
'stroke': 'none',
}),
fill: this.svg.box(options, position).attrs({'stroke': 'none'}),
};
}
BaseTheme.renderCross = (attrs, {x, y, radius}) => {
return svg.make('path', Object.assign({
'd': (
'M' + (x - radius) + ' ' + (y - radius) +
'l' + (radius * 2) + ' ' + (radius * 2) +
'm0 ' + (-radius * 2) +
'l' + (-radius * 2) + ' ' + (radius * 2)
),
}, attrs));
};
renderFlatConnect(
pattern,
attrs,
{x1, y1, x2, y2}
) {
return {
shape: this.svg.el('path')
.attr('d', this.svg.patternedLine(pattern)
.move(x1, y1)
.line(x2, y2)
.cap()
.asPath()
)
.attrs(attrs),
p1: {x: x1, y: y1},
p2: {x: x2, y: y2},
};
}
BaseTheme.renderRef = (options, position) => {
return {
shape: SVGShapes.renderBox(Object.assign({}, options, {
'fill': 'none',
}), position),
mask: SVGShapes.renderBox(Object.assign({}, options, {
'fill': '#000000',
'stroke': 'none',
}), position),
fill: SVGShapes.renderBox(Object.assign({}, options, {
'stroke': 'none',
}), position),
};
};
renderRevConnect(
pattern,
attrs,
{x1, y1, x2, y2, xR, rad}
) {
const maxRad = (y2 - y1) / 2;
const line = this.svg.patternedLine(pattern)
.move(x1, y1)
.line(xR, y1);
if(rad < maxRad) {
line
.arc(xR, y1 + rad, Math.PI / 2)
.line(xR + rad, y2 - rad)
.arc(xR, y2 - rad, Math.PI / 2);
} else {
line.arc(xR, (y1 + y2) / 2, Math.PI);
}
return {
shape: this.svg.el('path')
.attr('d', line
.line(x2, y2)
.cap()
.asPath()
)
.attrs(attrs),
p1: {x: x1, y: y1},
p2: {x: x2, y: y2},
};
}
renderLineDivider(
{lineAttrs},
{x, y, labelWidth, width, height}
) {
let shape = null;
const yPos = y + height / 2;
if(labelWidth > 0) {
shape = this.svg.el('g').add(
this.svg.line({'fill': 'none'}, {
'x1': x,
'y1': yPos,
'x2': x + (width - labelWidth) / 2,
'y2': yPos,
}).attrs(lineAttrs),
this.svg.line({'fill': 'none'}, {
'x1': x + (width + labelWidth) / 2,
'y1': yPos,
'x2': x + width,
'y2': yPos,
}).attrs(lineAttrs)
);
} else {
shape = this.svg.line({'fill': 'none'}, {
'x1': x,
'y1': yPos,
'x2': x + width,
'y2': yPos,
}).attrs(lineAttrs);
}
return {shape};
}
renderDelayDivider(
{dotSize, gapSize},
{x, y, width, height}
) {
const mask = this.svg.el('g');
for(let i = 0; i + gapSize <= height; i += dotSize + gapSize) {
mask.add(this.svg.box({
'fill': '#000000',
}, {
'x': x,
'y': y + i,
'width': width,
'height': gapSize,
}));
}
return {mask};
}
renderTearDivider(
{fadeBegin, fadeSize, pattern, zigWidth, zigHeight, lineAttrs},
{x, y, labelWidth, labelHeight, width, height, env}
) {
const maskGradID = env.addDef('tear-grad', () => {
const px = 100 / width;
return this.svg.linearGradient({}, [
{
'offset': (fadeBegin * px) + '%',
'stop-color': '#000000',
},
{
'offset': ((fadeBegin + fadeSize) * px) + '%',
'stop-color': '#FFFFFF',
},
{
'offset': (100 - (fadeBegin + fadeSize) * px) + '%',
'stop-color': '#FFFFFF',
},
{
'offset': (100 - fadeBegin * px) + '%',
'stop-color': '#000000',
},
]);
});
const shapeMask = this.svg.el('mask')
.attr('maskUnits', 'userSpaceOnUse')
.add(
this.svg.box({
'fill': 'url(#' + maskGradID + ')',
}, {
'x': x,
'y': y - 5,
'width': width,
'height': height + 10,
})
);
const shapeMaskID = env.addDef(shapeMask);
if(labelWidth > 0) {
shapeMask.add(this.svg.box({
'rx': 2,
'ry': 2,
'fill': '#000000',
}, {
'x': x + (width - labelWidth) / 2,
'y': y + (height - labelHeight) / 2 - 1,
'width': labelWidth,
'height': labelHeight + 2,
}));
}
if(!pattern) {
pattern = new BaseTheme.WavePattern(
zigWidth,
[zigHeight, -zigHeight]
);
}
let mask = null;
const pathTop = this.svg.patternedLine(pattern)
.move(x, y)
.line(x + width, y);
const shape = this.svg.el('g')
.attr('mask', 'url(#' + shapeMaskID + ')')
.add(
this.svg.el('path')
.attrs({
'd': pathTop.asPath(),
'fill': 'none',
})
.attrs(lineAttrs)
);
if(height > 0) {
const pathBase = this.svg.patternedLine(pattern)
.move(x, y + height)
.line(x + width, y + height);
shape.add(
this.svg.el('path')
.attrs({
'd': pathBase.asPath(),
'fill': 'none',
})
.attrs(lineAttrs)
);
pathTop
.line(pathBase.x, pathBase.y, {patterned: false})
.cap();
pathTop.points.push(...pathBase.points.reverse());
mask = this.svg.el('path').attrs({
'd': pathTop.asPath(),
'fill': '#000000',
});
}
return {shape, mask};
}
}
BaseTheme.WavePattern = class WavePattern {
constructor(width, height) {
@ -197,195 +357,5 @@ define([
}
};
BaseTheme.renderFlatConnector = (
pattern,
attrs,
{x1, y1, x2, y2}
) => {
return {
shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(pattern)
.move(x1, y1)
.line(x2, y2)
.cap()
.asPath(),
}, attrs)),
p1: {x: x1, y: y1},
p2: {x: x2, y: y2},
};
};
BaseTheme.renderRevConnector = (
pattern,
attrs,
{x1, y1, x2, y2, xR, rad}
) => {
const maxRad = (y2 - y1) / 2;
const line = new SVGShapes.PatternedLine(pattern)
.move(x1, y1)
.line(xR, y1);
if(rad < maxRad) {
line
.arc(xR, y1 + rad, Math.PI / 2)
.line(xR + rad, y2 - rad)
.arc(xR, y2 - rad, Math.PI / 2);
} else {
line.arc(xR, (y1 + y2) / 2, Math.PI);
}
return {
shape: svg.make('path', Object.assign({
d: line
.line(x2, y2)
.cap()
.asPath(),
}, attrs)),
p1: {x: x1, y: y1},
p2: {x: x2, y: y2},
};
};
BaseTheme.renderLineDivider = (
{lineAttrs},
{x, y, labelWidth, width, height}
) => {
let shape = null;
const yPos = y + height / 2;
if(labelWidth > 0) {
shape = svg.make('g', {}, [
svg.make('line', Object.assign({
'x1': x,
'y1': yPos,
'x2': x + (width - labelWidth) / 2,
'y2': yPos,
'fill': 'none',
}, lineAttrs)),
svg.make('line', Object.assign({
'x1': x + (width + labelWidth) / 2,
'y1': yPos,
'x2': x + width,
'y2': yPos,
'fill': 'none',
}, lineAttrs)),
]);
} else {
shape = svg.make('line', Object.assign({
'x1': x,
'y1': yPos,
'x2': x + width,
'y2': yPos,
'fill': 'none',
}, lineAttrs));
}
return {shape};
};
BaseTheme.renderDelayDivider = (
{dotSize, gapSize},
{x, y, width, height}
) => {
const mask = svg.make('g');
for(let i = 0; i + gapSize <= height; i += dotSize + gapSize) {
mask.appendChild(svg.make('rect', {
'x': x,
'y': y + i,
'width': width,
'height': gapSize,
'fill': '#000000',
}));
}
return {mask};
};
BaseTheme.renderTearDivider = (
{fadeBegin, fadeSize, pattern, zigWidth, zigHeight, lineAttrs},
{x, y, labelWidth, labelHeight, width, height, env}
) => {
const maskGradID = env.addDef('tear-grad', () => {
const px = 100 / width;
return svg.make('linearGradient', {}, [
svg.make('stop', {
'offset': (fadeBegin * px) + '%',
'stop-color': '#000000',
}),
svg.make('stop', {
'offset': ((fadeBegin + fadeSize) * px) + '%',
'stop-color': '#FFFFFF',
}),
svg.make('stop', {
'offset': (100 - (fadeBegin + fadeSize) * px) + '%',
'stop-color': '#FFFFFF',
}),
svg.make('stop', {
'offset': (100 - fadeBegin * px) + '%',
'stop-color': '#000000',
}),
]);
});
const shapeMask = svg.make('mask', {
'maskUnits': 'userSpaceOnUse',
}, [
svg.make('rect', {
'x': x,
'y': y - 5,
'width': width,
'height': height + 10,
'fill': 'url(#' + maskGradID + ')',
}),
]);
const shapeMaskID = env.addDef(shapeMask);
if(labelWidth > 0) {
shapeMask.appendChild(svg.make('rect', {
'x': x + (width - labelWidth) / 2,
'y': y + (height - labelHeight) / 2 - 1,
'width': labelWidth,
'height': labelHeight + 2,
'rx': 2,
'ry': 2,
'fill': '#000000',
}));
}
if(!pattern) {
pattern = new BaseTheme.WavePattern(
zigWidth,
[zigHeight, -zigHeight]
);
}
let mask = null;
const pathTop = new SVGShapes.PatternedLine(pattern)
.move(x, y)
.line(x + width, y);
const shape = svg.make('g', {
'mask': 'url(#' + shapeMaskID + ')',
}, [
svg.make('path', Object.assign({
'd': pathTop.asPath(),
'fill': 'none',
}, lineAttrs)),
]);
if(height > 0) {
const pathBase = new SVGShapes.PatternedLine(pattern)
.move(x, y + height)
.line(x + width, y + height);
shape.appendChild(svg.make('path', Object.assign({
'd': pathBase.asPath(),
'fill': 'none',
}, lineAttrs)));
pathTop
.line(pathBase.x, pathBase.y, {patterned: false})
.cap();
pathTop.points.push(...pathBase.points.reverse());
mask = svg.make('path', {
'd': pathTop.asPath(),
'fill': '#000000',
});
}
return {shape, mask};
};
return BaseTheme;
});

View File

@ -1,12 +1,4 @@
define([
'./BaseTheme',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseTheme,
svg,
SVGShapes
) => {
define(['./BaseTheme'], (BaseTheme) => {
'use strict';
const FONT = 'sans-serif';
@ -14,339 +6,12 @@ define([
const WAVE = new BaseTheme.WavePattern(6, 0.5);
const SETTINGS = {
titleMargin: 10,
outerMargin: 5,
agentMargin: 10,
actionMargin: 10,
minActionMargin: 3,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 5,
left: 10,
right: 10,
bottom: 5,
},
arrowBottom: 5 + 12 * 1.3 / 2,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
database: {
padding: {
top: 12,
left: 10,
right: 10,
bottom: 3,
},
arrowBottom: 5 + 12 * 1.3 / 2,
boxRenderer: BaseTheme.renderDB.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'db-z': 5,
}),
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 20,
render: BaseTheme.renderCross.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
bar: {
height: 4,
render: SVGShapes.renderBox.bind(null, {
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
}),
},
fade: {
width: 5,
height: 6,
extend: 1,
},
none: {
height: 10,
},
},
connect: {
loopbackRadius: 6,
line: {
'solid': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null),
},
'dash': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-dasharray': '4, 2',
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null),
},
'wave': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, WAVE),
renderRev: BaseTheme.renderRevConnector.bind(null, WAVE),
},
},
arrow: {
'single': {
width: 5,
height: 10,
render: BaseTheme.renderArrowHead,
attrs: {
'fill': '#000000',
'stroke-width': 0,
'stroke-linejoin': 'miter',
},
},
'double': {
width: 4,
height: 6,
render: BaseTheme.renderArrowHead,
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'miter',
},
},
'cross': {
short: 7,
radius: 3,
render: BaseTheme.renderCross.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
},
label: {
padding: 6,
margin: {top: 2, bottom: 1},
attrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
source: {
radius: 2,
render: ({x, y, radius}) => {
return svg.make('circle', {
'cx': x,
'cy': y,
'r': radius,
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
});
},
},
mask: {
padding: {
top: 0,
left: 3,
right: 3,
bottom: 1,
},
},
},
titleAttrs: {
'font-family': FONT,
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
'red': {
'stroke': '#CC0000',
},
},
};
const SHARED_BLOCK_SECTION = {
padding: {
top: 3,
bottom: 2,
},
tag: {
padding: {
top: 1,
left: 3,
right: 3,
bottom: 0,
},
boxRenderer: BaseTheme.renderTag.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 2,
'ry': 2,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
minHeight: 4,
padding: {
top: 1,
left: 5,
right: 3,
bottom: 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
};
const BLOCKS = {
'ref': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: BaseTheme.renderRef.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
}),
section: SHARED_BLOCK_SECTION,
},
'': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
}),
collapsedBoxRenderer: BaseTheme.renderRef.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
}),
section: SHARED_BLOCK_SECTION,
sepRenderer: SVGShapes.renderLine.bind(null, {
'stroke': '#000000',
'stroke-width': 1.5,
'stroke-dasharray': '4, 2',
}),
},
};
const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const NOTES = {
'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
}),
labelAttrs: NOTE_ATTRS,
},
'note': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 5, right: 10, bottom: 5},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderNote.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: NOTE_ATTRS,
},
'state': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 7, left: 7, right: 7, bottom: 7},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 10,
'ry': 10,
}),
labelAttrs: NOTE_ATTRS,
},
};
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
@ -354,61 +19,386 @@ define([
'text-anchor': 'middle',
};
const DIVIDERS = {
'': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: () => ({}),
},
'line': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 0,
render: BaseTheme.renderLineDivider.bind(null, {
lineAttrs: {
'stroke': '#000000',
},
}),
},
'delay': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: BaseTheme.renderDelayDivider.bind(null, {
dotSize: 1,
gapSize: 2,
}),
},
'tear': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 10,
render: BaseTheme.renderTearDivider.bind(null, {
fadeBegin: 5,
fadeSize: 10,
zigWidth: 6,
zigHeight: 1,
lineAttrs: {
'stroke': '#000000',
},
}),
},
};
class BasicTheme extends BaseTheme {
constructor(svg) {
super(svg);
return class BasicTheme extends BaseTheme {
constructor() {
super({
name: 'basic',
settings: SETTINGS,
blocks: BLOCKS,
notes: NOTES,
dividers: DIVIDERS,
const sharedBlockSection = {
padding: {
top: 3,
bottom: 2,
},
tag: {
padding: {
top: 1,
left: 3,
right: 3,
bottom: 0,
},
boxRenderer: this.renderTag.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 2,
'ry': 2,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
minHeight: 4,
padding: {
top: 1,
left: 5,
right: 3,
bottom: 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
};
Object.assign(this, {
titleMargin: 10,
outerMargin: 5,
agentMargin: 10,
actionMargin: 10,
minActionMargin: 3,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 5,
left: 10,
right: 10,
bottom: 5,
},
arrowBottom: 5 + 12 * 1.3 / 2,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
database: {
padding: {
top: 12,
left: 10,
right: 10,
bottom: 3,
},
arrowBottom: 5 + 12 * 1.3 / 2,
boxRenderer: this.renderDB.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'db-z': 5,
}),
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 20,
render: svg.crossFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
bar: {
height: 4,
render: svg.boxFactory({
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
}),
},
fade: {
width: 5,
height: 6,
extend: 1,
},
none: {
height: 10,
},
},
connect: {
loopbackRadius: 6,
line: {
'solid': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: this.renderRevConnect.bind(this, null),
},
'dash': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-dasharray': '4, 2',
},
renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: this.renderRevConnect.bind(this, null),
},
'wave': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
renderFlat: this.renderFlatConnect.bind(this, WAVE),
renderRev: this.renderRevConnect.bind(this, WAVE),
},
},
arrow: {
'single': {
width: 5,
height: 10,
render: this.renderArrowHead.bind(this),
attrs: {
'fill': '#000000',
'stroke-width': 0,
'stroke-linejoin': 'miter',
},
},
'double': {
width: 4,
height: 6,
render: this.renderArrowHead.bind(this),
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'miter',
},
},
'cross': {
short: 7,
radius: 3,
render: svg.crossFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
},
label: {
padding: 6,
margin: {top: 2, bottom: 1},
attrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
source: {
radius: 2,
render: svg.circleFactory({
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
}),
},
mask: {
padding: {
top: 0,
left: 3,
right: 3,
bottom: 1,
},
},
},
titleAttrs: {
'font-family': FONT,
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
'red': {
'stroke': '#CC0000',
},
},
blocks: {
'ref': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
}),
section: sharedBlockSection,
},
'': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: svg.boxFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
}),
collapsedBoxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
}),
section: sharedBlockSection,
sepRenderer: svg.lineFactory({
'stroke': '#000000',
'stroke-width': 1.5,
'stroke-dasharray': '4, 2',
}),
},
},
notes: {
'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10},
boxRenderer: svg.boxFactory({
'fill': '#FFFFFF',
}),
labelAttrs: NOTE_ATTRS,
},
'note': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 5, right: 10, bottom: 5},
overlap: {left: 10, right: 10},
boxRenderer: svg.noteFactory({
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: NOTE_ATTRS,
},
'state': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 7, left: 7, right: 7, bottom: 7},
overlap: {left: 10, right: 10},
boxRenderer: svg.boxFactory({
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 10,
'ry': 10,
}),
labelAttrs: NOTE_ATTRS,
},
},
dividers: {
'': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: () => ({}),
},
'line': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 0,
render: this.renderLineDivider.bind(this, {
lineAttrs: {
'stroke': '#000000',
},
}),
},
'delay': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: this.renderDelayDivider.bind(this, {
dotSize: 1,
gapSize: 2,
}),
},
'tear': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 10,
render: this.renderTearDivider.bind(this, {
fadeBegin: 5,
fadeSize: 10,
zigWidth: 6,
zigHeight: 1,
lineAttrs: {
'stroke': '#000000',
},
}),
},
},
});
}
}
BasicTheme.Factory = class {
constructor() {
this.name = 'basic';
}
build(svg) {
return new BasicTheme(svg);
}
};
return BasicTheme;
});

View File

@ -1,10 +1,21 @@
defineDescribe('Basic Theme', ['./Basic'], (BasicTheme) => {
defineDescribe('Basic Theme', [
'./Basic',
'svg/SVG',
'stubs/TestDOM',
], (
BasicTheme,
SVG,
TestDOM
) => {
'use strict';
const theme = new BasicTheme();
const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
const themeFactory = new BasicTheme.Factory();
const theme = themeFactory.build(svg);
it('has a name', () => {
expect(theme.name).toEqual('basic');
expect(themeFactory.name).toEqual('basic');
});
it('contains settings for the theme', () => {

View File

@ -1,12 +1,4 @@
define([
'./BaseTheme',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseTheme,
svg,
SVGShapes
) => {
define(['./BaseTheme'], (BaseTheme) => {
'use strict';
const FONT = 'sans-serif';
@ -14,352 +6,12 @@ define([
const WAVE = new BaseTheme.WavePattern(10, 1);
const SETTINGS = {
titleMargin: 12,
outerMargin: 5,
agentMargin: 8,
actionMargin: 5,
minActionMargin: 5,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 1,
left: 3,
right: 3,
bottom: 1,
},
arrowBottom: 2 + 14 * 1.3 / 2,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'rx': 4,
'ry': 4,
},
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 14,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
database: {
padding: {
top: 4,
left: 3,
right: 3,
bottom: 0,
},
arrowBottom: 2 + 14 * 1.3 / 2,
boxRenderer: BaseTheme.renderDB.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'db-z': 2,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 14,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 20,
render: BaseTheme.renderCross.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linecap': 'round',
}),
},
bar: {
height: 4,
render: SVGShapes.renderBox.bind(null, {
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
'rx': 2,
'ry': 2,
}),
},
fade: {
width: 5,
height: 10,
extend: 1,
},
none: {
height: 10,
},
},
connect: {
loopbackRadius: 8,
line: {
'solid': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null),
},
'dash': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-dasharray': '10, 4',
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null),
},
'wave': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, WAVE),
renderRev: BaseTheme.renderRevConnector.bind(null, WAVE),
},
},
arrow: {
'single': {
width: 10,
height: 12,
render: BaseTheme.renderArrowHead,
attrs: {
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
},
},
'double': {
width: 10,
height: 12,
render: BaseTheme.renderArrowHead,
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
},
'cross': {
short: 10,
radius: 5,
render: BaseTheme.renderCross.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
}),
},
},
label: {
padding: 7,
margin: {top: 2, bottom: 3},
attrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
source: {
radius: 5,
render: ({x, y, radius}) => {
return svg.make('circle', {
'cx': x,
'cy': y,
'r': radius,
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
});
},
},
mask: {
padding: {
top: 1,
left: 5,
right: 5,
bottom: 3,
},
},
},
titleAttrs: {
'font-family': FONT,
'font-weight': 'bolder',
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
'red': {
'stroke': '#DD0000',
},
},
};
const SHARED_BLOCK_SECTION = {
padding: {
top: 3,
bottom: 4,
},
tag: {
padding: {
top: 2,
left: 5,
right: 5,
bottom: 1,
},
boxRenderer: BaseTheme.renderTag.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
'rx': 3,
'ry': 3,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
minHeight: 5,
padding: {
top: 2,
left: 5,
right: 3,
bottom: 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
};
const BLOCKS = {
'ref': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: BaseTheme.renderRef.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 4,
'rx': 5,
'ry': 5,
}),
section: SHARED_BLOCK_SECTION,
},
'': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 4,
'rx': 5,
'ry': 5,
}),
collapsedBoxRenderer: BaseTheme.renderRef.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 4,
'rx': 5,
'ry': 5,
}),
section: SHARED_BLOCK_SECTION,
sepRenderer: SVGShapes.renderLine.bind(null, {
'stroke': '#000000',
'stroke-width': 2,
'stroke-dasharray': '5, 3',
}),
},
};
const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const NOTES = {
'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
}),
labelAttrs: NOTE_ATTRS,
},
'note': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 3, left: 3, right: 10, bottom: 3},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderNote.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
'stroke-linejoin': 'round',
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: NOTE_ATTRS,
},
'state': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 7, right: 7, bottom: 5},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'rx': 10,
'ry': 10,
}),
labelAttrs: NOTE_ATTRS,
},
};
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
@ -367,65 +19,403 @@ define([
'text-anchor': 'middle',
};
const DIVIDERS = {
'': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: () => ({}),
},
'line': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 0,
render: BaseTheme.renderLineDivider.bind(null, {
lineAttrs: {
'stroke': '#000000',
'stroke-width': 2,
'stroke-linecap': 'round',
},
}),
},
'delay': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: BaseTheme.renderDelayDivider.bind(null, {
dotSize: 3,
gapSize: 3,
}),
},
'tear': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 10,
render: BaseTheme.renderTearDivider.bind(null, {
fadeBegin: 5,
fadeSize: 10,
zigWidth: 6,
zigHeight: 1,
lineAttrs: {
'stroke': '#000000',
'stroke-width': 2,
'stroke-linejoin': 'round',
},
}),
},
};
class ChunkyTheme extends BaseTheme {
constructor(svg) {
super(svg);
return class ChunkyTheme extends BaseTheme {
constructor() {
super({
name: 'chunky',
settings: SETTINGS,
blocks: BLOCKS,
notes: NOTES,
dividers: DIVIDERS,
const sharedBlockSection = {
padding: {
top: 3,
bottom: 4,
},
tag: {
padding: {
top: 2,
left: 5,
right: 5,
bottom: 1,
},
boxRenderer: this.renderTag.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
'rx': 3,
'ry': 3,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
minHeight: 5,
padding: {
top: 2,
left: 5,
right: 3,
bottom: 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
};
Object.assign(this, {
titleMargin: 12,
outerMargin: 5,
agentMargin: 8,
actionMargin: 5,
minActionMargin: 5,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 1,
left: 3,
right: 3,
bottom: 1,
},
arrowBottom: 2 + 14 * 1.3 / 2,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'rx': 4,
'ry': 4,
},
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 14,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
database: {
padding: {
top: 4,
left: 3,
right: 3,
bottom: 0,
},
arrowBottom: 2 + 14 * 1.3 / 2,
boxRenderer: this.renderDB.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'db-z': 2,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 14,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 20,
render: svg.crossFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linecap': 'round',
}),
},
bar: {
height: 4,
render: svg.boxFactory({
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
'rx': 2,
'ry': 2,
}),
},
fade: {
width: 5,
height: 10,
extend: 1,
},
none: {
height: 10,
},
},
connect: {
loopbackRadius: 8,
line: {
'solid': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: this.renderRevConnect.bind(this, null),
},
'dash': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-dasharray': '10, 4',
},
renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: this.renderRevConnect.bind(this, null),
},
'wave': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
renderFlat: this.renderFlatConnect.bind(this, WAVE),
renderRev: this.renderRevConnect.bind(this, WAVE),
},
},
arrow: {
'single': {
width: 10,
height: 12,
render: this.renderArrowHead.bind(this),
attrs: {
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
},
},
'double': {
width: 10,
height: 12,
render: this.renderArrowHead.bind(this),
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
},
'cross': {
short: 10,
radius: 5,
render: svg.crossFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
}),
},
},
label: {
padding: 7,
margin: {top: 2, bottom: 3},
attrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
source: {
radius: 5,
render: svg.circleFactory({
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
}),
},
mask: {
padding: {
top: 1,
left: 5,
right: 5,
bottom: 3,
},
},
},
titleAttrs: {
'font-family': FONT,
'font-weight': 'bolder',
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
'red': {
'stroke': '#DD0000',
},
},
blocks: {
'ref': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 4,
'rx': 5,
'ry': 5,
}),
section: sharedBlockSection,
},
'': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: svg.boxFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 4,
'rx': 5,
'ry': 5,
}),
collapsedBoxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 4,
'rx': 5,
'ry': 5,
}),
section: sharedBlockSection,
sepRenderer: svg.lineFactory({
'stroke': '#000000',
'stroke-width': 2,
'stroke-dasharray': '5, 3',
}),
},
},
notes: {
'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10},
boxRenderer: svg.boxFactory({
'fill': '#FFFFFF',
}),
labelAttrs: NOTE_ATTRS,
},
'note': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 3, left: 3, right: 10, bottom: 3},
overlap: {left: 10, right: 10},
boxRenderer: svg.noteFactory({
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
'stroke-linejoin': 'round',
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: NOTE_ATTRS,
},
'state': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 7, right: 7, bottom: 5},
overlap: {left: 10, right: 10},
boxRenderer: svg.boxFactory({
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'rx': 10,
'ry': 10,
}),
labelAttrs: NOTE_ATTRS,
},
},
dividers: {
'': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: () => ({}),
},
'line': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 0,
render: this.renderLineDivider.bind(this, {
lineAttrs: {
'stroke': '#000000',
'stroke-width': 2,
'stroke-linecap': 'round',
},
}),
},
'delay': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: this.renderDelayDivider.bind(this, {
dotSize: 3,
gapSize: 3,
}),
},
'tear': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10,
margin: 10,
render: this.renderTearDivider.bind(this, {
fadeBegin: 5,
fadeSize: 10,
zigWidth: 6,
zigHeight: 1,
lineAttrs: {
'stroke': '#000000',
'stroke-width': 2,
'stroke-linejoin': 'round',
},
}),
},
},
});
}
}
ChunkyTheme.Factory = class {
constructor() {
this.name = 'chunky';
}
build(svg) {
return new ChunkyTheme(svg);
}
};
return ChunkyTheme;
});

View File

@ -1,10 +1,21 @@
defineDescribe('Chunky Theme', ['./Chunky'], (ChunkyTheme) => {
defineDescribe('Chunky Theme', [
'./Chunky',
'svg/SVG',
'stubs/TestDOM',
], (
ChunkyTheme,
SVG,
TestDOM
) => {
'use strict';
const theme = new ChunkyTheme();
const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
const themeFactory = new ChunkyTheme.Factory();
const theme = themeFactory.build(svg);
it('has a name', () => {
expect(theme.name).toEqual('chunky');
expect(themeFactory.name).toEqual('chunky');
});
it('contains settings for the theme', () => {

View File

@ -1,12 +1,4 @@
define([
'./BaseTheme',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseTheme,
svg,
SVGShapes
) => {
define(['./BaseTheme'], (BaseTheme) => {
'use strict';
const FONT = 'monospace';
@ -23,331 +15,12 @@ define([
+0.25,
]);
const SETTINGS = {
titleMargin: 8,
outerMargin: 4,
agentMargin: 12,
actionMargin: 12,
minActionMargin: 4,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 4,
left: 8,
right: 8,
bottom: 4,
},
arrowBottom: 12,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
database: {
padding: {
top: 9,
left: 8,
right: 8,
bottom: 3,
},
arrowBottom: 12,
boxRenderer: BaseTheme.renderDB.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'db-z': 4,
}),
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 16,
render: BaseTheme.renderCross.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
bar: {
height: 4,
render: SVGShapes.renderBox.bind(null, {
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
}),
},
fade: {
width: 5,
height: 8,
extend: 1,
},
none: {
height: 8,
},
},
connect: {
loopbackRadius: 4,
line: {
'solid': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null),
},
'dash': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-dasharray': '4, 4',
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null),
},
'wave': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
renderFlat: BaseTheme.renderFlatConnector.bind(null, WAVE),
renderRev: BaseTheme.renderRevConnector.bind(null, WAVE),
},
},
arrow: {
'single': {
width: 4,
height: 8,
render: BaseTheme.renderArrowHead,
attrs: {
'fill': '#000000',
'stroke-width': 0,
'stroke-linejoin': 'miter',
},
},
'double': {
width: 3,
height: 6,
render: BaseTheme.renderArrowHead,
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'miter',
},
},
'cross': {
short: 8,
radius: 4,
render: BaseTheme.renderCross.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
},
label: {
padding: 4,
margin: {top: 2, bottom: 1},
attrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
source: {
radius: 2,
render: ({x, y, radius}) => {
return svg.make('circle', {
'cx': x,
'cy': y,
'r': radius,
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
});
},
},
mask: {
padding: {
top: 0,
left: 3,
right: 3,
bottom: 1,
},
},
},
titleAttrs: {
'font-family': FONT,
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
'red': {
'stroke': '#AA0000',
},
},
};
const SHARED_BLOCK_SECTION = {
padding: {
top: 3,
bottom: 2,
},
tag: {
padding: {
top: 2,
left: 4,
right: 4,
bottom: 2,
},
boxRenderer: BaseTheme.renderTag.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 3,
'ry': 3,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
minHeight: 8,
padding: {
top: 2,
left: 8,
right: 8,
bottom: 2,
},
labelAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
};
const BLOCKS = {
'ref': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: BaseTheme.renderRef.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
}),
section: SHARED_BLOCK_SECTION,
},
'': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 2,
}),
collapsedBoxRenderer: BaseTheme.renderRef.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
}),
section: SHARED_BLOCK_SECTION,
sepRenderer: SVGShapes.renderLine.bind(null, {
'stroke': '#000000',
'stroke-width': 2,
'stroke-dasharray': '8, 4',
}),
},
};
const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const NOTES = {
'text': {
margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 4, left: 4, right: 4, bottom: 4},
overlap: {left: 8, right: 8},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
}),
labelAttrs: NOTE_ATTRS,
},
'note': {
margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 8, left: 8, right: 8, bottom: 8},
overlap: {left: 8, right: 8},
boxRenderer: SVGShapes.renderNote.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: NOTE_ATTRS,
},
'state': {
margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 8, left: 8, right: 8, bottom: 8},
overlap: {left: 8, right: 8},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 8,
'ry': 8,
}),
labelAttrs: NOTE_ATTRS,
},
};
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
@ -355,61 +28,378 @@ define([
'text-anchor': 'middle',
};
const DIVIDERS = {
'': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: () => ({}),
},
'line': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 8,
margin: 0,
render: BaseTheme.renderLineDivider.bind(null, {
lineAttrs: {
'stroke': '#000000',
},
}),
},
'delay': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: BaseTheme.renderDelayDivider.bind(null, {
dotSize: 2,
gapSize: 2,
}),
},
'tear': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 8,
margin: 8,
render: BaseTheme.renderTearDivider.bind(null, {
fadeBegin: 4,
fadeSize: 4,
zigWidth: 4,
zigHeight: 1,
lineAttrs: {
'stroke': '#000000',
},
}),
},
};
class MonospaceTheme extends BaseTheme {
constructor(svg) {
super(svg);
return class MonospaceTheme extends BaseTheme {
constructor() {
super({
name: 'monospace',
settings: SETTINGS,
blocks: BLOCKS,
notes: NOTES,
dividers: DIVIDERS,
const sharedBlockSection = {
padding: {
top: 3,
bottom: 2,
},
tag: {
padding: {
top: 2,
left: 4,
right: 4,
bottom: 2,
},
boxRenderer: this.renderTag.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 3,
'ry': 3,
}),
labelAttrs: {
'font-family': FONT,
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
minHeight: 8,
padding: {
top: 2,
left: 8,
right: 8,
bottom: 2,
},
labelAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
};
Object.assign(this, {
titleMargin: 8,
outerMargin: 4,
agentMargin: 12,
actionMargin: 12,
minActionMargin: 4,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 4,
left: 8,
right: 8,
bottom: 4,
},
arrowBottom: 12,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
},
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
database: {
padding: {
top: 9,
left: 8,
right: 8,
bottom: 3,
},
arrowBottom: 12,
boxRenderer: this.renderDB.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'db-z': 4,
}),
labelAttrs: {
'font-family': FONT,
'font-size': 12,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 16,
render: svg.crossFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
bar: {
height: 4,
render: svg.boxFactory({
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
}),
},
fade: {
width: 5,
height: 8,
extend: 1,
},
none: {
height: 8,
},
},
connect: {
loopbackRadius: 4,
line: {
'solid': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: this.renderRevConnect.bind(this, null),
},
'dash': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-dasharray': '4, 4',
},
renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: this.renderRevConnect.bind(this, null),
},
'wave': {
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
renderFlat: this.renderFlatConnect.bind(this, WAVE),
renderRev: this.renderRevConnect.bind(this, WAVE),
},
},
arrow: {
'single': {
width: 4,
height: 8,
render: this.renderArrowHead.bind(this),
attrs: {
'fill': '#000000',
'stroke-width': 0,
'stroke-linejoin': 'miter',
},
},
'double': {
width: 3,
height: 6,
render: this.renderArrowHead.bind(this),
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'miter',
},
},
'cross': {
short: 8,
radius: 4,
render: svg.crossFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
},
},
label: {
padding: 4,
margin: {top: 2, bottom: 1},
attrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
source: {
radius: 2,
render: svg.circleFactory({
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 1,
}),
},
mask: {
padding: {
top: 0,
left: 3,
right: 3,
bottom: 1,
},
},
},
titleAttrs: {
'font-family': FONT,
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
},
'red': {
'stroke': '#AA0000',
},
},
blocks: {
'ref': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
}),
section: sharedBlockSection,
},
'': {
margin: {
top: 0,
bottom: 0,
},
boxRenderer: svg.boxFactory({
'fill': 'none',
'stroke': '#000000',
'stroke-width': 2,
}),
collapsedBoxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 2,
}),
section: sharedBlockSection,
sepRenderer: svg.lineFactory({
'stroke': '#000000',
'stroke-width': 2,
'stroke-dasharray': '8, 4',
}),
},
},
notes: {
'text': {
margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 4, left: 4, right: 4, bottom: 4},
overlap: {left: 8, right: 8},
boxRenderer: svg.boxFactory({
'fill': '#FFFFFF',
}),
labelAttrs: NOTE_ATTRS,
},
'note': {
margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 8, left: 8, right: 8, bottom: 8},
overlap: {left: 8, right: 8},
boxRenderer: svg.noteFactory({
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: NOTE_ATTRS,
},
'state': {
margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 8, left: 8, right: 8, bottom: 8},
overlap: {left: 8, right: 8},
boxRenderer: svg.boxFactory({
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 8,
'ry': 8,
}),
labelAttrs: NOTE_ATTRS,
},
},
dividers: {
'': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: () => ({}),
},
'line': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 8,
margin: 0,
render: this.renderLineDivider.bind(this, {
lineAttrs: {
'stroke': '#000000',
},
}),
},
'delay': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0,
margin: 0,
render: this.renderDelayDivider.bind(this, {
dotSize: 2,
gapSize: 2,
}),
},
'tear': {
labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 8,
margin: 8,
render: this.renderTearDivider.bind(this, {
fadeBegin: 4,
fadeSize: 4,
zigWidth: 4,
zigHeight: 1,
lineAttrs: {
'stroke': '#000000',
},
}),
},
},
});
}
}
MonospaceTheme.Factory = class {
constructor() {
this.name = 'monospace';
}
build(svg) {
return new MonospaceTheme(svg);
}
};
return MonospaceTheme;
});

View File

@ -1,10 +1,21 @@
defineDescribe('Monospace Theme', ['./Monospace'], (MonospaceTheme) => {
defineDescribe('Monospace Theme', [
'./Monospace',
'svg/SVG',
'stubs/TestDOM',
], (
MonospaceTheme,
SVG,
TestDOM
) => {
'use strict';
const theme = new MonospaceTheme();
const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
const themeFactory = new MonospaceTheme.Factory();
const theme = themeFactory.build(svg);
it('has a name', () => {
expect(theme.name).toEqual('monospace');
expect(themeFactory.name).toEqual('monospace');
});
it('contains settings for the theme', () => {

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,31 @@
defineDescribe('Sketch Theme', ['./Sketch'], (SketchTheme) => {
defineDescribe('Sketch Theme', [
'./Sketch',
'svg/SVG',
'stubs/TestDOM',
], (
SketchTheme,
SVG,
TestDOM
) => {
'use strict';
const theme = new SketchTheme(SketchTheme.RIGHT);
const themeL = new SketchTheme(SketchTheme.LEFT);
const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
const themeFactory = new SketchTheme.Factory(SketchTheme.RIGHT);
const themeFactoryL = new SketchTheme.Factory(SketchTheme.LEFT);
const theme = themeFactory.build(svg);
const themeL = themeFactory.build(svg);
it('has a name', () => {
expect(theme.name).toEqual('sketch');
expect(themeFactory.name).toEqual('sketch');
});
it('has a left-handed variant', () => {
expect(themeL.name).toEqual('sketch left handed');
expect(themeFactoryL.name).toEqual('sketch left handed');
});
it('contains settings for the theme', () => {
expect(theme.outerMargin).toEqual(5);
expect(themeL.outerMargin).toEqual(5);
});
});

View File

@ -1,9 +1,10 @@
define([
'core/ArrayUtilities_spec',
'core/EventObject_spec',
'svg/SVGUtilities_spec',
'core/Random_spec',
'core/documents/VirtualDocument_spec',
'svg/SVG_spec',
'svg/SVGTextBlock_spec',
'svg/SVGShapes_spec',
'svg/PatternedLine_spec',
'interface/Interface_spec',
'interface/ComponentsLibrary_spec',

View File

@ -1,191 +0,0 @@
define(['svg/SVGUtilities'], (svg) => {
'use strict';
// Simplified text block renderer, which assumes all characters render as
// 1x1 px squares for repeatable renders in all browsers
function merge(state, newState) {
for(const k in state) {
if(state.hasOwnProperty(k)) {
if(newState[k] !== null && newState[k] !== undefined) {
state[k] = newState[k];
}
}
}
}
const EMPTY = [];
class SVGTextBlock {
constructor(container, initialState = {}) {
this.container = container;
this.state = {
attrs: {},
formatted: EMPTY,
x: 0,
y: 0,
};
this.nodes = [];
this.set(initialState);
}
_rebuildNodes(count) {
if(count > this.nodes.length) {
const attrs = Object.assign({
'x': this.state.x,
}, this.state.attrs);
while(this.nodes.length < count) {
const text = svg.makeText();
const element = svg.make('text', attrs, [text]);
this.container.appendChild(element);
this.nodes.push({element, text});
}
} else {
while(this.nodes.length > count) {
const {element} = this.nodes.pop();
this.container.removeChild(element);
}
}
}
_reset() {
this._rebuildNodes(0);
}
_renderText() {
if(!this.state.formatted) {
this._reset();
return;
}
const formatted = this.state.formatted;
this._rebuildNodes(formatted.length);
this.nodes.forEach(({text, element}, i) => {
const ln = formatted[i].reduce((v, pt) => v + pt.text, '');
text.nodeValue = ln;
});
}
_updateX() {
this.nodes.forEach(({element}) => {
element.setAttribute('x', this.state.x);
});
}
_updateY() {
this.nodes.forEach(({element}, i) => {
element.setAttribute('y', this.state.y + i);
});
}
firstLine() {
if(this.nodes.length > 0) {
return this.nodes[0].element;
} else {
return null;
}
}
set(newState) {
const oldState = Object.assign({}, this.state);
merge(this.state, newState);
if(this.state.attrs !== oldState.attrs) {
this._reset();
oldState.formatted = EMPTY;
}
const oldNodes = this.nodes.length;
if(this.state.formatted !== oldState.formatted) {
this._renderText();
}
if(this.state.x !== oldState.x) {
this._updateX();
}
if(this.state.y !== oldState.y || this.nodes.length !== oldNodes) {
this._updateY();
}
}
}
class SizeTester {
constructor() {
this.expected = new Set();
this.measured = new Set();
}
_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;
opts.formatted.forEach((line) => {
const length = line.reduce((v, pt) => v + pt.text.length, 0);
width = Math.max(width, length);
});
return {
width,
height: opts.formatted.length,
};
}
measureHeight(attrs, formatted) {
if(!formatted) {
return 0;
}
return formatted.length;
}
resetCache() {
this.expected.clear();
this.measured.clear();
}
}
SVGTextBlock.SizeTester = SizeTester;
return SVGTextBlock;
});

44
scripts/stubs/TestDOM.js Normal file
View File

@ -0,0 +1,44 @@
define([
'core/documents/VirtualDocument',
'core/DOMWrapper',
], (
VirtualDocument,
DOMWrapper
) => {
'use strict';
class UnitaryTextSizer {
// Simplified text sizer, which assumes all characters render as
// 1x1 px squares for repeatable renders in all browsers
baseline() {
return 1;
}
measureHeight({formatted}) {
return formatted.length;
}
prepMeasurement(attrs, formatted) {
return formatted;
}
prepComplete() {
}
performMeasurement(data) {
return data.reduce((total, part) => total + part.text.length, 0);
}
teardown() {
}
}
return {
VirtualDocument,
UnitaryTextSizer,
DOMWrapper,
textSizerFactory: () => new UnitaryTextSizer(),
dom: new DOMWrapper(new VirtualDocument()),
};
});

View File

@ -1,4 +1,4 @@
define([], () => {
define(() => {
'use strict';
function Split(elements, options) {

360
scripts/svg/SVG.js Normal file
View File

@ -0,0 +1,360 @@
define([
'./SVGTextBlock',
'./PatternedLine',
], (
SVGTextBlock,
PatternedLine
) => {
'use strict';
const NS = 'http://www.w3.org/2000/svg';
function calculateAnchor(x, attrs, padding) {
let shift = 0;
let anchorX = x;
switch(attrs['text-anchor']) {
case 'middle':
shift = 0.5;
anchorX += (padding.left - padding.right) / 2;
break;
case 'end':
shift = 1;
anchorX -= padding.right;
break;
default:
shift = 0;
anchorX += padding.left;
break;
}
return {shift, anchorX};
}
const defaultTextSizerFactory = (svg) => new SVGTextBlock.TextSizer(svg);
class TextSizerWrapper {
constructor(sizer) {
this.sizer = sizer;
this.cache = new Map();
this.active = 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;
}
_measureLine(attrCache, line) {
if(!line.length) {
return 0;
}
const labelKey = JSON.stringify(line);
const cache = attrCache.lines.get(labelKey);
if(cache.width === null) {
window.console.warn('Performing unexpected measurement', line);
this.performMeasurements();
}
return cache.width;
}
_measureWidth(opts) {
if(!opts.formatted.length) {
return 0;
}
const attrCache = this._expectMeasure(opts);
return (opts.formatted
.map((line) => this._measureLine(attrCache, line))
.reduce((a, b) => Math.max(a, b), 0)
);
}
_getMeasurementOpts(attrs, formatted) {
if(!formatted) {
formatted = [];
if(attrs.textBlock) {
attrs = attrs.textBlock;
}
if(attrs.state) {
formatted = attrs.state.formatted || [];
attrs = attrs.state.attrs;
}
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
}
return {attrs, formatted};
}
expectMeasure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
this._expectMeasure(opts);
}
performMeasurementsPre() {
this.active = [];
this.cache.forEach(({attrs, lines}) => {
lines.forEach((cacheLine) => {
if(cacheLine.width === null) {
this.active.push({
data: this.sizer.prepMeasurement(
attrs,
cacheLine.formatted
),
cacheLine,
});
}
});
});
if(this.active.length) {
this.sizer.prepComplete();
}
}
performMeasurementsAct() {
this.active.forEach(({data, cacheLine}) => {
cacheLine.width = this.sizer.performMeasurement(data);
});
}
performMeasurementsPost() {
if(this.active.length) {
this.sizer.teardown();
}
this.active = null;
}
performMeasurements() {
// getComputedTextLength forces a reflow, so we try to batch as
// many measurements as possible into a single DOM change
try {
this.performMeasurementsPre();
this.performMeasurementsAct();
} finally {
this.performMeasurementsPost();
}
}
measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return {
width: this._measureWidth(opts),
height: this.sizer.measureHeight(opts),
};
}
baseline(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return this.sizer.baseline(opts);
}
measureHeight(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return this.sizer.measureHeight(opts);
}
resetCache() {
this.cache.clear();
}
}
return class SVG {
constructor(domWrapper, textSizerFactory = null) {
this.dom = domWrapper;
this.body = this.el('svg').attrs({'xmlns': NS, 'version': '1.1'});
const fn = (textSizerFactory || defaultTextSizerFactory);
this.textSizer = new TextSizerWrapper(fn(this));
this.txt = this.txt.bind(this);
this.el = this.el.bind(this);
}
linearGradient(attrs, stops) {
return this.el('linearGradient')
.attrs(attrs)
.add(stops.map((stop) => this.el('stop').attrs(stop)));
}
patternedLine(pattern = null, phase = 0) {
return new PatternedLine(pattern, phase);
}
txt(content) {
return this.dom.txt(content);
}
el(tag, namespace = NS) {
return this.dom.el(tag, namespace);
}
box(attrs, position) {
return this.el('rect').attrs(attrs).attrs(position);
}
boxFactory(attrs) {
return this.box.bind(this, attrs);
}
line(attrs, position) {
return this.el('line').attrs(attrs).attrs(position);
}
lineFactory(attrs) {
return this.line.bind(this, attrs);
}
circle(attrs, {x, y, radius}) {
return this.el('circle')
.attrs({
'cx': x,
'cy': y,
'r': radius,
})
.attrs(attrs);
}
circleFactory(attrs) {
return this.circle.bind(this, attrs);
}
cross(attrs, {x, y, radius}) {
return this.el('path')
.attr('d', (
'M' + (x - radius) + ' ' + (y - radius) +
'l' + (radius * 2) + ' ' + (radius * 2) +
'm0 ' + (-radius * 2) +
'l' + (-radius * 2) + ' ' + (radius * 2)
))
.attrs(attrs);
}
crossFactory(attrs) {
return this.cross.bind(this, attrs);
}
note(attrs, flickAttrs, position) {
const x0 = position.x;
const x1 = position.x + position.width;
const y0 = position.y;
const y1 = position.y + position.height;
const flick = 7;
return this.el('g').add(
this.el('polygon')
.attr('points', (
x0 + ' ' + y0 + ' ' +
(x1 - flick) + ' ' + y0 + ' ' +
x1 + ' ' + (y0 + flick) + ' ' +
x1 + ' ' + y1 + ' ' +
x0 + ' ' + y1
))
.attrs(attrs),
this.el('polyline')
.attr('points', (
(x1 - flick) + ' ' + y0 + ' ' +
(x1 - flick) + ' ' + (y0 + flick) + ' ' +
x1 + ' ' + (y0 + flick)
))
.attrs(flickAttrs)
);
}
noteFactory(attrs, flickAttrs) {
return this.note.bind(this, attrs, flickAttrs);
}
formattedText(attrs = {}, formatted = [], position = {}) {
const container = this.el('g');
const txt = new SVGTextBlock(container, this, {
attrs,
formatted,
x: position.x,
y: position.y,
});
return Object.assign(container, {
set: (state) => txt.set(state),
textBlock: txt,
});
}
formattedTextFactory(attrs) {
return this.formattedText.bind(this, attrs);
}
boxedText({
padding,
labelAttrs,
boxAttrs = {},
boxRenderer = null,
}, formatted, {x, y}) {
if(!formatted || !formatted.length) {
return Object.assign(this.el('g'), {
width: 0,
height: 0,
box: null,
label: null,
});
}
const {shift, anchorX} = calculateAnchor(x, labelAttrs, padding);
const label = this.formattedText(labelAttrs, formatted, {
x: anchorX,
y: y + padding.top,
});
const size = this.textSizer.measure(label);
const width = (size.width + padding.left + padding.right);
const height = (size.height + padding.top + padding.bottom);
const boxFn = boxRenderer || this.boxFactory(boxAttrs);
const box = boxFn({
'x': anchorX - size.width * shift - padding.left,
'y': y,
'width': width,
'height': height,
});
return Object.assign(this.el('g').add(box, label), {
width,
height,
box,
label,
});
}
boxedTextFactory(options) {
return this.boxedText.bind(this, options);
}
};
});

View File

@ -1,130 +0,0 @@
define([
'./SVGUtilities',
'./SVGTextBlock',
'./PatternedLine',
], (
svg,
SVGTextBlock,
PatternedLine
) => {
'use strict';
function renderBox(attrs, position) {
return svg.make('rect', Object.assign({}, position, attrs));
}
function renderLine(attrs, position) {
return svg.make('line', Object.assign({}, position, attrs));
}
function renderNote(attrs, flickAttrs, position) {
const x0 = position.x;
const x1 = position.x + position.width;
const y0 = position.y;
const y1 = position.y + position.height;
const flick = 7;
return svg.make('g', {}, [
svg.make('polygon', Object.assign({
'points': (
x0 + ' ' + y0 + ' ' +
(x1 - flick) + ' ' + y0 + ' ' +
x1 + ' ' + (y0 + flick) + ' ' +
x1 + ' ' + y1 + ' ' +
x0 + ' ' + y1
),
}, attrs)),
svg.make('polyline', Object.assign({
'points': (
(x1 - flick) + ' ' + y0 + ' ' +
(x1 - flick) + ' ' + (y0 + flick) + ' ' +
x1 + ' ' + (y0 + flick)
),
}, flickAttrs)),
]);
}
function calculateAnchor(x, attrs, padding) {
let shift = 0;
let anchorX = x;
switch(attrs['text-anchor']) {
case 'middle':
shift = 0.5;
anchorX += (padding.left - padding.right) / 2;
break;
case 'end':
shift = 1;
anchorX -= padding.right;
break;
default:
shift = 0;
anchorX += padding.left;
break;
}
return {shift, anchorX};
}
function renderBoxedText(formatted, {
x,
y,
padding,
boxAttrs,
labelAttrs,
boxLayer,
labelLayer,
boxRenderer = null,
SVGTextBlockClass = SVGTextBlock,
textSizer,
}) {
if(!formatted || !formatted.length) {
return {width: 0, height: 0, label: null, box: null};
}
const {shift, anchorX} = calculateAnchor(x, labelAttrs, padding);
const label = new SVGTextBlockClass(labelLayer, {
attrs: labelAttrs,
formatted,
x: anchorX,
y: y + padding.top,
});
const size = textSizer.measure(label);
const width = (size.width + padding.left + padding.right);
const height = (size.height + padding.top + padding.bottom);
let box = null;
if(boxRenderer) {
box = boxRenderer({
'x': anchorX - size.width * shift - padding.left,
'y': y,
'width': width,
'height': height,
});
} else {
box = renderBox(boxAttrs, {
'x': anchorX - size.width * shift - padding.left,
'y': y,
'width': width,
'height': height,
});
}
if(boxLayer === labelLayer) {
boxLayer.insertBefore(box, label.firstLine());
} else {
boxLayer.appendChild(box);
}
return {width, height, label, box};
}
return {
renderBox,
renderLine,
renderNote,
renderBoxedText,
TextBlock: SVGTextBlock,
PatternedLine,
};
});

View File

@ -1,120 +0,0 @@
defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
'use strict';
describe('renderBox', () => {
it('returns a simple rect SVG element', () => {
const node = SVGShapes.renderBox({
'foo': 'bar',
}, {
'x': 10,
'y': 20,
'width': 30,
'height': 40,
});
expect(node.tagName).toEqual('rect');
expect(node.getAttribute('foo')).toEqual('bar');
expect(node.getAttribute('x')).toEqual('10');
expect(node.getAttribute('y')).toEqual('20');
expect(node.getAttribute('width')).toEqual('30');
expect(node.getAttribute('height')).toEqual('40');
});
});
describe('renderNote', () => {
it('returns a group containing a rectangle with a page flick', () => {
const node = SVGShapes.renderNote({
'foo': 'bar',
}, {
'zig': 'zag',
}, {
'x': 10,
'y': 20,
'width': 30,
'height': 40,
});
expect(node.tagName).toEqual('g');
expect(node.children.length).toEqual(2);
const back = node.children[0];
expect(back.getAttribute('foo')).toEqual('bar');
expect(back.getAttribute('points')).toEqual(
'10 20 ' +
'33 20 ' +
'40 27 ' +
'40 60 ' +
'10 60'
);
const flick = node.children[1];
expect(flick.getAttribute('zig')).toEqual('zag');
expect(flick.getAttribute('points')).toEqual(
'33 20 ' +
'33 27 ' +
'40 27'
);
});
});
describe('renderBoxedText', () => {
let o = null;
let sizer = null;
beforeEach(() => {
o = document.createElement('p');
sizer = {
measure: () => ({width: 64, height: 128}),
};
});
it('renders a label', () => {
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1,
y: 2,
padding: {left: 4, top: 8, right: 16, bottom: 32},
boxAttrs: {},
labelAttrs: {'font-size': 10, 'line-height': 1.5, 'foo': 'bar'},
boxLayer: o,
labelLayer: o,
textSizer: sizer,
});
expect(rendered.label.state.formatted).toEqual([[{text: 'foo'}]]);
expect(rendered.label.state.x).toEqual(5);
expect(rendered.label.state.y).toEqual(10);
expect(rendered.label.firstLine().parentNode).toEqual(o);
});
it('positions a box beneath the rendered label', () => {
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1,
y: 2,
padding: {left: 4, top: 8, right: 16, bottom: 32},
boxAttrs: {'foo': 'bar'},
labelAttrs: {'font-size': 10, 'line-height': 1.5},
boxLayer: o,
labelLayer: o,
textSizer: sizer,
});
expect(rendered.box.getAttribute('x')).toEqual('1');
expect(rendered.box.getAttribute('y')).toEqual('2');
expect(rendered.box.getAttribute('width'))
.toEqual(String(4 + 16 + 64));
expect(rendered.box.getAttribute('height'))
.toEqual(String(8 + 32 + 128));
expect(rendered.box.getAttribute('foo')).toEqual('bar');
expect(rendered.box.parentNode).toEqual(o);
});
it('returns the size of the rendered box', () => {
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1,
y: 2,
padding: {left: 4, top: 8, right: 16, bottom: 32},
boxAttrs: {},
labelAttrs: {'font-size': 10, 'line-height': 1.5},
boxLayer: o,
labelLayer: o,
textSizer: sizer,
});
expect(rendered.width).toEqual(4 + 16 + 64);
expect(rendered.height).toEqual(8 + 32 + 128);
});
});
});

View File

@ -1,18 +1,9 @@
define(['./SVGUtilities'], (svg) => {
define(() => {
'use strict';
// Thanks, https://stackoverflow.com/a/9851769/1180785
const firefox = (typeof window.InstallTrigger !== 'undefined');
function fontDetails(attrs) {
const size = Number(attrs['font-size']);
const lineHeight = size * (Number(attrs['line-height']) || 1);
return {
size,
lineHeight,
};
}
function merge(state, newState) {
for(const k in state) {
if(state.hasOwnProperty(k)) {
@ -23,17 +14,15 @@ define(['./SVGUtilities'], (svg) => {
}
}
function populateSvgTextLine(node, formattedLine) {
function populateSvgTextLine(svg, node, formattedLine) {
if(!Array.isArray(formattedLine)) {
throw new Error('Invalid formatted text line: ' + formattedLine);
}
formattedLine.forEach(({text, attrs}) => {
const textNode = svg.makeText(text);
if(attrs) {
const span = svg.make('tspan', attrs, [textNode]);
node.appendChild(span);
node.add(svg.el('tspan').attrs(attrs).add(text));
} else {
node.appendChild(textNode);
node.add(text);
}
});
}
@ -41,8 +30,9 @@ define(['./SVGUtilities'], (svg) => {
const EMPTY = [];
class SVGTextBlock {
constructor(container, initialState = {}) {
constructor(container, svg, initialState = {}) {
this.container = container;
this.svg = svg;
this.state = {
attrs: {},
formatted: EMPTY,
@ -55,19 +45,18 @@ define(['./SVGUtilities'], (svg) => {
_rebuildLines(count) {
if(count > this.lines.length) {
const attrs = Object.assign({
'x': this.state.x,
}, this.state.attrs);
while(this.lines.length < count) {
const node = svg.make('text', attrs);
this.container.appendChild(node);
this.lines.push({node, latest: ''});
this.lines.push({
node: this.svg.el('text')
.attr('x', this.state.x)
.attrs(this.state.attrs)
.attach(this.container),
latest: '',
});
}
} else {
while(this.lines.length > count) {
const {node} = this.lines.pop();
this.container.removeChild(node);
this.lines.pop().node.detach();
}
}
}
@ -92,8 +81,8 @@ define(['./SVGUtilities'], (svg) => {
this.lines.forEach((ln, i) => {
const id = JSON.stringify(formatted[i]);
if(id !== ln.latest) {
svg.empty(ln.node);
populateSvgTextLine(ln.node, formatted[i]);
ln.node.empty();
populateSvgTextLine(this.svg, ln.node, formatted[i]);
ln.latest = id;
}
});
@ -101,22 +90,18 @@ define(['./SVGUtilities'], (svg) => {
_updateX() {
this.lines.forEach(({node}) => {
node.setAttribute('x', this.state.x);
node.attr('x', this.state.x);
});
}
_updateY() {
const {size, lineHeight} = fontDetails(this.state.attrs);
this.lines.forEach(({node}, i) => {
node.setAttribute('y', this.state.y + i * lineHeight + size);
});
}
firstLine() {
if(this.lines.length > 0) {
return this.lines[0].node;
} else {
return null;
const sizer = this.svg.textSizer;
let y = this.state.y;
for(let i = 0; i < this.lines.length; ++ i) {
const line = [this.state.formatted[i]];
const baseline = sizer.baseline(this.state.attrs, line);
this.lines[i].node.attr('y', y + baseline);
y += sizer.measureHeight(this.state.attrs, line);
}
}
@ -145,160 +130,47 @@ define(['./SVGUtilities'], (svg) => {
}
}
class SizeTester {
constructor(container) {
this.testers = svg.make('g', {
SVGTextBlock.TextSizer = class TextSizer {
constructor(svg) {
this.svg = svg;
this.testers = this.svg.el('g').attrs({
// Firefox fails to measure non-displayed text
'display': firefox ? 'block' : 'none',
'visibility': 'hidden',
});
this.container = container;
this.cache = new Map();
this.nodes = null;
this.container = svg.body;
}
_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;
baseline({attrs}) {
return Number(attrs['font-size']);
}
_measureHeight({attrs, formatted}) {
return formatted.length * fontDetails(attrs).lineHeight;
measureHeight({attrs, formatted}) {
const size = this.baseline({attrs, formatted});
const lineHeight = size * (Number(attrs['line-height']) || 1);
return formatted.length * lineHeight;
}
_measureLine(attrCache, line) {
if(!line.length) {
return 0;
}
const labelKey = JSON.stringify(line);
const cache = attrCache.lines.get(labelKey);
if(cache.width === null) {
window.console.warn('Performing unexpected measurement', line);
this.performMeasurements();
}
return cache.width;
prepMeasurement(attrs, formatted) {
const node = this.svg.el('text')
.attrs(attrs)
.attach(this.testers);
populateSvgTextLine(this.svg, node, formatted);
return node;
}
_measureWidth(opts) {
if(!opts.formatted.length) {
return 0;
}
const attrCache = this._expectMeasure(opts);
return (opts.formatted
.map((line) => this._measureLine(attrCache, line))
.reduce((a, b) => Math.max(a, b), 0)
);
prepComplete() {
this.container.add(this.testers);
}
_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};
performMeasurement(node) {
return node.element.getComputedTextLength();
}
expectMeasure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
this._expectMeasure(opts);
teardown() {
this.container.del(this.testers.empty());
}
performMeasurementsPre() {
this.nodes = [];
this.cache.forEach(({attrs, lines}) => {
lines.forEach((cacheLine) => {
if(cacheLine.width === null) {
const node = svg.make('text', attrs);
populateSvgTextLine(node, cacheLine.formatted);
this.testers.appendChild(node);
this.nodes.push({node, cacheLine});
}
});
});
if(this.nodes.length) {
this.container.appendChild(this.testers);
}
}
performMeasurementsAct() {
this.nodes.forEach(({node, cacheLine}) => {
cacheLine.width = node.getComputedTextLength();
});
}
performMeasurementsPost() {
if(this.nodes.length) {
this.container.removeChild(this.testers);
svg.empty(this.testers);
}
this.nodes = null;
}
performMeasurements() {
// getComputedTextLength forces a reflow, so we try to batch as
// many measurements as possible into a single DOM change
this.performMeasurementsPre();
this.performMeasurementsAct();
this.performMeasurementsPost();
}
measure(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return {
width: this._measureWidth(opts),
height: this._measureHeight(opts),
};
}
measureHeight(attrs, formatted) {
const opts = this._getMeasurementOpts(attrs, formatted);
return this._measureHeight(opts);
}
resetCache() {
this.cache.clear();
}
}
SVGTextBlock.SizeTester = SizeTester;
};
return SVGTextBlock;
});

View File

@ -1,24 +1,24 @@
defineDescribe('SVGTextBlock', [
'./SVGTextBlock',
'./SVGUtilities',
'./SVG',
'stubs/TestDOM',
], (
SVGTextBlock,
svg
SVG,
TestDOM
) => {
'use strict';
const attrs = {'font-size': 10, 'line-height': 1.5};
let hold = null;
let svg = null;
let block = null;
let hold = null;
beforeEach(() => {
hold = svg.makeContainer();
document.body.appendChild(hold);
block = new SVGTextBlock(hold, {attrs});
});
afterEach(() => {
document.body.removeChild(hold);
svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
block = new SVGTextBlock(svg.body, svg, {attrs});
hold = svg.body.element;
});
describe('constructor', () => {
@ -26,28 +26,28 @@ defineDescribe('SVGTextBlock', [
expect(block.state.formatted).toEqual([]);
expect(block.state.x).toEqual(0);
expect(block.state.y).toEqual(0);
expect(hold.children.length).toEqual(0);
expect(hold.childNodes.length).toEqual(0);
});
it('does not explode if given no setup', () => {
block = new SVGTextBlock(hold);
block = new SVGTextBlock(svg.body, svg);
expect(block.state.formatted).toEqual([]);
expect(block.state.x).toEqual(0);
expect(block.state.y).toEqual(0);
expect(hold.children.length).toEqual(0);
expect(hold.childNodes.length).toEqual(0);
});
it('adds the given formatted text if specified', () => {
block = new SVGTextBlock(hold, {
block = new SVGTextBlock(svg.body, svg, {
attrs,
formatted: [[{text: 'abc'}]],
});
expect(block.state.formatted).toEqual([[{text: 'abc'}]]);
expect(hold.children.length).toEqual(1);
expect(hold.childNodes.length).toEqual(1);
});
it('uses the given coordinates if specified', () => {
block = new SVGTextBlock(hold, {attrs, x: 5, y: 7});
block = new SVGTextBlock(svg.body, svg, {attrs, x: 5, y: 7});
expect(block.state.x).toEqual(5);
expect(block.state.y).toEqual(7);
});
@ -57,21 +57,21 @@ defineDescribe('SVGTextBlock', [
it('sets the text to the given content', () => {
block.set({formatted: [[{text: 'foo'}]]});
expect(block.state.formatted).toEqual([[{text: 'foo'}]]);
expect(hold.children.length).toEqual(1);
expect(hold.children[0].innerHTML).toEqual('foo');
expect(hold.childNodes.length).toEqual(1);
expect(hold.childNodes[0].innerHTML).toEqual('foo');
});
it('renders multiline text', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
expect(hold.children.length).toEqual(2);
expect(hold.children[0].innerHTML).toEqual('foo');
expect(hold.children[1].innerHTML).toEqual('bar');
expect(hold.childNodes.length).toEqual(2);
expect(hold.childNodes[0].innerHTML).toEqual('foo');
expect(hold.childNodes[1].innerHTML).toEqual('bar');
});
it('re-uses text nodes when possible, adding more if needed', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
const line0 = hold.children[0];
const line1 = hold.children[1];
const line0 = hold.childNodes[0];
const line1 = hold.childNodes[1];
block.set({formatted: [
[{text: 'zig'}],
@ -79,77 +79,83 @@ defineDescribe('SVGTextBlock', [
[{text: 'baz'}],
]});
expect(hold.children.length).toEqual(3);
expect(hold.children[0]).toEqual(line0);
expect(hold.children[0].innerHTML).toEqual('zig');
expect(hold.children[1]).toEqual(line1);
expect(hold.children[1].innerHTML).toEqual('zag');
expect(hold.children[2].innerHTML).toEqual('baz');
expect(hold.childNodes.length).toEqual(3);
expect(hold.childNodes[0]).toEqual(line0);
expect(hold.childNodes[0].innerHTML).toEqual('zig');
expect(hold.childNodes[1]).toEqual(line1);
expect(hold.childNodes[1].innerHTML).toEqual('zag');
expect(hold.childNodes[2].innerHTML).toEqual('baz');
});
it('re-uses text nodes when possible, removing extra if needed', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
const line0 = hold.children[0];
const line0 = hold.childNodes[0];
block.set({formatted: [[{text: 'zig'}]]});
expect(hold.children.length).toEqual(1);
expect(hold.children[0]).toEqual(line0);
expect(hold.children[0].innerHTML).toEqual('zig');
expect(hold.childNodes.length).toEqual(1);
expect(hold.childNodes[0]).toEqual(line0);
expect(hold.childNodes[0].innerHTML).toEqual('zig');
});
it('positions text nodes and applies attributes', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
expect(hold.children.length).toEqual(2);
expect(hold.children[0].getAttribute('x')).toEqual('0');
expect(hold.children[0].getAttribute('y')).toEqual('10');
expect(hold.children[0].getAttribute('font-size')).toEqual('10');
expect(hold.children[1].getAttribute('x')).toEqual('0');
expect(hold.children[1].getAttribute('y')).toEqual('25');
expect(hold.children[1].getAttribute('font-size')).toEqual('10');
expect(hold.childNodes.length).toEqual(2);
expect(hold.childNodes[0].getAttribute('x')).toEqual('0');
expect(hold.childNodes[0].getAttribute('y')).toEqual('1');
expect(hold.childNodes[0].getAttribute('font-size')).toEqual('10');
expect(hold.childNodes[1].getAttribute('x')).toEqual('0');
expect(hold.childNodes[1].getAttribute('y')).toEqual('2');
expect(hold.childNodes[1].getAttribute('font-size')).toEqual('10');
});
it('moves all nodes', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
block.set({x: 5, y: 7});
expect(hold.children[0].getAttribute('x')).toEqual('5');
expect(hold.children[0].getAttribute('y')).toEqual('17');
expect(hold.children[1].getAttribute('x')).toEqual('5');
expect(hold.children[1].getAttribute('y')).toEqual('32');
expect(hold.childNodes[0].getAttribute('x')).toEqual('5');
expect(hold.childNodes[0].getAttribute('y')).toEqual('8');
expect(hold.childNodes[1].getAttribute('x')).toEqual('5');
expect(hold.childNodes[1].getAttribute('y')).toEqual('9');
});
it('clears if the text is empty', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
block.set({formatted: []});
expect(hold.children.length).toEqual(0);
expect(hold.childNodes.length).toEqual(0);
expect(block.state.formatted).toEqual([]);
expect(block.lines.length).toEqual(0);
});
});
describe('SizeTester', () => {
let tester = null;
describe('TextSizer', () => {
beforeEach(() => {
tester = new SVGTextBlock.SizeTester(hold);
svg = new SVG(
new TestDOM.DOMWrapper(window.document),
(svgBase) => new SVGTextBlock.TextSizer(svgBase)
);
document.body.appendChild(svg.body.element);
});
afterEach(() => {
document.body.removeChild(svg.body.element);
});
describe('.measure', () => {
it('calculates the size of the formatted text', () => {
const size = tester.measure(attrs, [[{text: 'foo'}]]);
const size = svg.textSizer.measure(attrs, [[{text: 'foo'}]]);
expect(size.width).toBeGreaterThan(0);
expect(size.height).toEqual(15);
});
it('calculates the size of text blocks', () => {
block.set({formatted: [[{text: 'foo'}]]});
const size = tester.measure(block);
const size = svg.textSizer.measure(block);
expect(size.width).toBeGreaterThan(0);
expect(size.height).toEqual(15);
});
it('measures multiline text', () => {
const size = tester.measure(attrs, [
const size = svg.textSizer.measure(attrs, [
[{text: 'foo'}],
[{text: 'bar'}],
]);
@ -158,19 +164,19 @@ defineDescribe('SVGTextBlock', [
});
it('returns 0, 0 for empty content', () => {
const size = tester.measure(attrs, []);
const size = svg.textSizer.measure(attrs, []);
expect(size.width).toEqual(0);
expect(size.height).toEqual(0);
});
it('returns the maximum width for multiline text', () => {
const size0 = tester.measure(attrs, [
const size0 = svg.textSizer.measure(attrs, [
[{text: 'foo'}],
]);
const size1 = tester.measure(attrs, [
const size1 = svg.textSizer.measure(attrs, [
[{text: 'longline'}],
]);
const size = tester.measure(attrs, [
const size = svg.textSizer.measure(attrs, [
[{text: 'foo'}],
[{text: 'longline'}],
[{text: 'foo'}],
@ -182,14 +188,14 @@ defineDescribe('SVGTextBlock', [
describe('.measureHeight', () => {
it('calculates the height of the rendered text', () => {
const height = tester.measureHeight(attrs, [
const height = svg.textSizer.measureHeight(attrs, [
[{text: 'foo'}],
]);
expect(height).toEqual(15);
});
it('measures multiline text', () => {
const height = tester.measureHeight(attrs, [
const height = svg.textSizer.measureHeight(attrs, [
[{text: 'foo'}],
[{text: 'bar'}],
]);
@ -197,13 +203,13 @@ defineDescribe('SVGTextBlock', [
});
it('returns 0 for empty content', () => {
const height = tester.measureHeight(attrs, []);
const height = svg.textSizer.measureHeight(attrs, []);
expect(height).toEqual(0);
});
it('does not require the container', () => {
tester.measureHeight(attrs, [[{text: 'foo'}]]);
expect(hold.children.length).toEqual(0);
svg.textSizer.measureHeight(attrs, [[{text: 'foo'}]]);
expect(svg.body.element.childNodes.length).toEqual(0);
});
});
});

View File

@ -1,47 +0,0 @@
define(() => {
'use strict';
const NS = 'http://www.w3.org/2000/svg';
function makeText(text = '') {
return document.createTextNode(text);
}
function setAttributes(target, attrs) {
for(const k in attrs) {
if(attrs.hasOwnProperty(k)) {
target.setAttribute(k, attrs[k]);
}
}
}
function make(type, attrs = {}, children = []) {
const o = document.createElementNS(NS, type);
setAttributes(o, attrs);
for(const c of children) {
o.appendChild(c);
}
return o;
}
function makeContainer(attrs = {}) {
return make('svg', Object.assign({
'xmlns': NS,
'version': '1.1',
}, attrs));
}
function empty(node) {
while(node.childNodes.length > 0) {
node.removeChild(node.lastChild);
}
}
return {
makeText,
make,
makeContainer,
setAttributes,
empty,
};
});

View File

@ -1,58 +0,0 @@
defineDescribe('SVGUtilities', ['./SVGUtilities'], (svg) => {
'use strict';
const expectedNS = 'http://www.w3.org/2000/svg';
describe('.makeText', () => {
it('creates a text node with the given content', () => {
const node = svg.makeText('foo');
expect(node.nodeValue).toEqual('foo');
});
it('defaults to empty', () => {
const node = svg.makeText();
expect(node.nodeValue).toEqual('');
});
});
describe('.make', () => {
it('creates a node with the SVG namespace', () => {
const node = svg.make('path');
expect(node.namespaceURI).toEqual(expectedNS);
expect(node.tagName).toEqual('path');
});
it('assigns the given attributes', () => {
const node = svg.make('path', {'foo': 'bar'});
expect(node.getAttribute('foo')).toEqual('bar');
});
});
describe('.makeContainer', () => {
it('creates an svg node with the SVG namespace', () => {
const node = svg.makeContainer();
expect(node.namespaceURI).toEqual(expectedNS);
expect(node.getAttribute('xmlns')).toEqual(expectedNS);
expect(node.getAttribute('version')).toEqual('1.1');
expect(node.tagName).toEqual('svg');
});
it('assigns the given attributes', () => {
const node = svg.makeContainer({'foo': 'bar'});
expect(node.getAttribute('foo')).toEqual('bar');
});
});
describe('.empty', () => {
it('removes all child nodes from the given node', () => {
const node = document.createElement('p');
const a = document.createElement('p');
const b = document.createElement('p');
node.appendChild(a);
node.appendChild(b);
svg.empty(node);
expect(node.children.length).toEqual(0);
});
});
});

150
scripts/svg/SVG_spec.js Normal file
View File

@ -0,0 +1,150 @@
defineDescribe('SVG', [
'./SVG',
'stubs/TestDOM',
], (
SVG,
TestDOM
) => {
'use strict';
const expectedNS = 'http://www.w3.org/2000/svg';
const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
describe('.txt', () => {
it('creates a text node with the given content', () => {
const node = svg.txt('foo');
expect(node.nodeValue).toEqual('foo');
});
it('defaults to empty', () => {
const node = svg.txt();
expect(node.nodeValue).toEqual('');
});
});
describe('.el', () => {
it('creates a wrapped node with the SVG namespace', () => {
const node = svg.el('path').element;
expect(node.namespaceURI).toEqual(expectedNS);
expect(node.tagName).toEqual('path');
});
it('overrides the namespace if desired', () => {
const node = svg.el('path', 'foo').element;
expect(node.namespaceURI).toEqual('foo');
});
});
describe('.body', () => {
it('is a wrapped svg node with the SVG namespace', () => {
const node = svg.body.element;
expect(node.namespaceURI).toEqual(expectedNS);
expect(node.getAttribute('xmlns')).toEqual(expectedNS);
expect(node.getAttribute('version')).toEqual('1.1');
expect(node.tagName).toEqual('svg');
});
});
describe('.box', () => {
it('returns a wrapped simple rect SVG element', () => {
const node = svg.box({
'foo': 'bar',
}, {
'x': 10,
'y': 20,
'width': 30,
'height': 40,
}).element;
expect(node.tagName).toEqual('rect');
expect(node.getAttribute('foo')).toEqual('bar');
expect(node.getAttribute('x')).toEqual('10');
expect(node.getAttribute('y')).toEqual('20');
expect(node.getAttribute('width')).toEqual('30');
expect(node.getAttribute('height')).toEqual('40');
});
});
describe('.note', () => {
it('returns a wrapped rectangle with a page flick', () => {
const node = svg.note({
'foo': 'bar',
}, {
'zig': 'zag',
}, {
'x': 10,
'y': 20,
'width': 30,
'height': 40,
}).element;
expect(node.tagName).toEqual('g');
expect(node.childNodes.length).toEqual(2);
const back = node.childNodes[0];
expect(back.getAttribute('foo')).toEqual('bar');
expect(back.getAttribute('points')).toEqual(
'10 20 ' +
'33 20 ' +
'40 27 ' +
'40 60 ' +
'10 60'
);
const flick = node.childNodes[1];
expect(flick.getAttribute('zig')).toEqual('zag');
expect(flick.getAttribute('points')).toEqual(
'33 20 ' +
'33 27 ' +
'40 27'
);
});
});
describe('.boxedText', () => {
const PADDING = {left: 4, top: 8, right: 16, bottom: 32};
const LABEL_ATTRS = {'font-size': 10, 'line-height': 1.5, 'foo': 'bar'};
const LABEL = [[{text: 'foo'}]];
beforeEach(() => {
svg.textSizer.expectMeasure(LABEL_ATTRS, LABEL);
svg.textSizer.performMeasurements();
});
it('renders a label', () => {
const rendered = svg.boxedText({
padding: PADDING,
boxAttrs: {},
labelAttrs: LABEL_ATTRS,
}, LABEL, {x: 1, y: 2});
const block = rendered.label.textBlock;
expect(block.state.formatted).toEqual([[{text: 'foo'}]]);
expect(block.state.x).toEqual(5);
expect(block.state.y).toEqual(10);
});
it('positions a box beneath the rendered label', () => {
const rendered = svg.boxedText({
padding: PADDING,
boxAttrs: {'foo': 'bar'},
labelAttrs: LABEL_ATTRS,
}, LABEL, {x: 1, y: 2});
const box = rendered.element.childNodes[0];
expect(box.getAttribute('x')).toEqual('1');
expect(box.getAttribute('y')).toEqual('2');
expect(box.getAttribute('width')).toEqual(String(4 + 16 + 3));
expect(box.getAttribute('height')).toEqual(String(8 + 32 + 1));
expect(box.getAttribute('foo')).toEqual('bar');
});
it('returns the size of the rendered box', () => {
const rendered = svg.boxedText({
padding: PADDING,
boxAttrs: {},
labelAttrs: LABEL_ATTRS,
}, LABEL, {x: 1, y: 2});
expect(rendered.width).toEqual(4 + 16 + 3);
expect(rendered.height).toEqual(8 + 32 + 1);
});
});
});