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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
defineDescribe('Markdown Parser', [ defineDescribe('Markdown Parser', [
'./MarkdownParser', './MarkdownParser',
'svg/SVGTextBlock', 'svg/SVG',
'svg/SVGUtilities', 'stubs/TestDOM',
], ( ], (
parser, parser,
SVGTextBlock, SVG,
svg TestDOM
) => { ) => {
'use strict'; 'use strict';
@ -122,25 +122,12 @@ defineDescribe('Markdown Parser', [
]]); ]]);
}); });
describe('SVGTextBlock interaction', () => { it('produces a format compatible with SVG.formattedText', () => {
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'); const formatted = parser('hello everybody');
block.set({formatted}); const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
expect(hold.children.length).toEqual(1); const block = svg.formattedText({}, formatted).element;
expect(hold.children[0].innerHTML).toEqual('hello everybody'); expect(block.outerHTML).toEqual(
}); '<g><text x="0" y="1">hello everybody</text></g>'
);
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +1,6 @@
define([ define(() => {
'svg/SVGUtilities',
'svg/SVGShapes',
], (
svg,
SVGShapes
) => {
'use strict'; '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) { function optionsAttributes(attributes, options) {
let attrs = Object.assign({}, attributes['']); let attrs = Object.assign({}, attributes['']);
options.forEach((opt) => { options.forEach((opt) => {
@ -29,14 +10,12 @@ define([
} }
class BaseTheme { class BaseTheme {
constructor({name, settings, blocks, notes, dividers}) { constructor(svg) {
this.name = name; this.svg = svg;
this.blocks = deepCopy(blocks);
this.notes = deepCopy(notes);
this.dividers = deepCopy(dividers);
Object.assign(this, deepCopy(settings));
} }
// PUBLIC API
reset() { reset() {
} }
@ -62,43 +41,39 @@ define([
renderAgentLine({x, y0, y1, width, className, options}) { renderAgentLine({x, y0, y1, width, className, options}) {
const attrs = this.optionsAttributes(this.agentLineAttrs, options); const attrs = this.optionsAttributes(this.agentLineAttrs, options);
if(width > 0) { if(width > 0) {
return svg.make('rect', Object.assign({ return this.svg.box(attrs, {
'x': x - width / 2, 'x': x - width / 2,
'y': y0, 'y': y0,
'width': width, 'width': width,
'height': y1 - y0, 'height': y1 - y0,
'class': className, }).addClass(className);
}, attrs));
} else { } else {
return svg.make('line', Object.assign({ return this.svg.line(attrs, {
'x1': x, 'x1': x,
'y1': y0, 'y1': y0,
'x2': x, 'x2': x,
'y2': y1, 'y2': y1,
'class': className, }).addClass(className);
}, attrs));
}
} }
} }
BaseTheme.renderArrowHead = (attrs, {x, y, width, height, dir}) => { // INTERNAL HELPERS
renderArrowHead(attrs, {x, y, width, height, dir}) {
const wx = width * dir.dx; const wx = width * dir.dx;
const wy = width * dir.dy; const wy = width * dir.dy;
const hy = height * 0.5 * dir.dx; const hy = height * 0.5 * dir.dx;
const hx = -height * 0.5 * dir.dy; const hx = -height * 0.5 * dir.dy;
return svg.make( return this.svg.el(attrs.fill === 'none' ? 'polyline' : 'polygon')
attrs.fill === 'none' ? 'polyline' : 'polygon', .attr('points', (
Object.assign({
'points': (
(x + wx - hx) + ' ' + (y + wy - hy) + ' ' + (x + wx - hx) + ' ' + (y + wy - hy) + ' ' +
x + ' ' + y + ' ' + x + ' ' + y + ' ' +
(x + wx + hx) + ' ' + (y + wy + hy) (x + wx + hx) + ' ' + (y + wy + hy)
), ))
}, attrs) .attrs(attrs);
); }
};
BaseTheme.renderTag = (attrs, {x, y, width, height}) => { renderTag(attrs, {x, y, width, height}) {
const {rx, ry} = attrs; const {rx, ry} = attrs;
const x2 = x + width; const x2 = x + width;
const y2 = y + height; const y2 = y + height;
@ -110,68 +85,253 @@ define([
'L' + x + ' ' + y2 'L' + x + ' ' + y2
); );
const g = svg.make('g'); const g = this.svg.el('g');
if(attrs.fill !== 'none') {
g.appendChild(svg.make('path', Object.assign({
'd': line + 'L' + x + ' ' + y,
}, attrs, {'stroke': 'none'})));
}
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') { if(attrs.stroke !== 'none') {
g.appendChild(svg.make('path', Object.assign({ g.add(this.svg.el('path')
'd': line, .attr('d', line)
}, attrs, {'fill': 'none'}))); .attrs(attrs)
.attr('fill', 'none')
);
} }
return g; return g;
}; }
BaseTheme.renderDB = (attrs, {x, y, width, height}) => { renderDB(attrs, position) {
const z = attrs['db-z']; const z = attrs['db-z'];
return svg.make('g', {}, [ return this.svg.el('g').add(
svg.make('rect', Object.assign({ this.svg.box({
'x': x, 'rx': position.width / 2,
'y': y,
'width': width,
'height': height,
'rx': width / 2,
'ry': z, 'ry': z,
}, attrs)), }, position).attrs(attrs),
svg.make('path', Object.assign({ this.svg.el('path')
'd': ( .attr('d', (
'M' + x + ' ' + (y + z) + 'M' + position.x + ' ' + (position.y + z) +
'a' + (width / 2) + ' ' + z + 'a' + (position.width / 2) + ' ' + z +
' 0 0 0 ' + width + ' 0' ' 0 0 0 ' + position.width + ' 0'
), ))
}, attrs, {'fill': 'none'})), .attrs(attrs)
]); .attr('fill', 'none')
}; );
}
BaseTheme.renderCross = (attrs, {x, y, radius}) => { renderRef(options, position) {
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));
};
BaseTheme.renderRef = (options, position) => {
return { return {
shape: SVGShapes.renderBox(Object.assign({}, options, { shape: this.svg.box(options, position).attrs({'fill': 'none'}),
'fill': 'none', mask: this.svg.box(options, position).attrs({
}), position),
mask: SVGShapes.renderBox(Object.assign({}, options, {
'fill': '#000000', 'fill': '#000000',
'stroke': 'none', 'stroke': 'none',
}), position), }),
fill: SVGShapes.renderBox(Object.assign({}, options, { fill: this.svg.box(options, position).attrs({'stroke': 'none'}),
'stroke': 'none',
}), position),
}; };
}
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},
}; };
}
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 { BaseTheme.WavePattern = class WavePattern {
constructor(width, height) { 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; return BaseTheme;
}); });

View File

@ -1,12 +1,4 @@
define([ define(['./BaseTheme'], (BaseTheme) => {
'./BaseTheme',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseTheme,
svg,
SVGShapes
) => {
'use strict'; 'use strict';
const FONT = 'sans-serif'; const FONT = 'sans-serif';
@ -14,7 +6,68 @@ define([
const WAVE = new BaseTheme.WavePattern(6, 0.5); const WAVE = new BaseTheme.WavePattern(6, 0.5);
const SETTINGS = { const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
};
class BasicTheme extends BaseTheme {
constructor(svg) {
super(svg);
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, titleMargin: 10,
outerMargin: 5, outerMargin: 5,
agentMargin: 10, agentMargin: 10,
@ -51,7 +104,7 @@ define([
bottom: 3, bottom: 3,
}, },
arrowBottom: 5 + 12 * 1.3 / 2, arrowBottom: 5 + 12 * 1.3 / 2,
boxRenderer: BaseTheme.renderDB.bind(null, { boxRenderer: this.renderDB.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -66,7 +119,7 @@ define([
}, },
cross: { cross: {
size: 20, size: 20,
render: BaseTheme.renderCross.bind(null, { render: svg.crossFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -74,7 +127,7 @@ define([
}, },
bar: { bar: {
height: 4, height: 4,
render: SVGShapes.renderBox.bind(null, { render: svg.boxFactory({
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -99,8 +152,8 @@ define([
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, null), renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null), renderRev: this.renderRevConnect.bind(this, null),
}, },
'dash': { 'dash': {
attrs: { attrs: {
@ -109,8 +162,8 @@ define([
'stroke-width': 1, 'stroke-width': 1,
'stroke-dasharray': '4, 2', 'stroke-dasharray': '4, 2',
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, null), renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null), renderRev: this.renderRevConnect.bind(this, null),
}, },
'wave': { 'wave': {
attrs: { attrs: {
@ -120,15 +173,15 @@ define([
'stroke-linejoin': 'round', 'stroke-linejoin': 'round',
'stroke-linecap': 'round', 'stroke-linecap': 'round',
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, WAVE), renderFlat: this.renderFlatConnect.bind(this, WAVE),
renderRev: BaseTheme.renderRevConnector.bind(null, WAVE), renderRev: this.renderRevConnect.bind(this, WAVE),
}, },
}, },
arrow: { arrow: {
'single': { 'single': {
width: 5, width: 5,
height: 10, height: 10,
render: BaseTheme.renderArrowHead, render: this.renderArrowHead.bind(this),
attrs: { attrs: {
'fill': '#000000', 'fill': '#000000',
'stroke-width': 0, 'stroke-width': 0,
@ -138,7 +191,7 @@ define([
'double': { 'double': {
width: 4, width: 4,
height: 6, height: 6,
render: BaseTheme.renderArrowHead, render: this.renderArrowHead.bind(this),
attrs: { attrs: {
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
@ -149,7 +202,7 @@ define([
'cross': { 'cross': {
short: 7, short: 7,
radius: 3, radius: 3,
render: BaseTheme.renderCross.bind(null, { render: svg.crossFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -173,16 +226,11 @@ define([
}, },
source: { source: {
radius: 2, radius: 2,
render: ({x, y, radius}) => { render: svg.circleFactory({
return svg.make('circle', {
'cx': x,
'cy': y,
'r': radius,
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
}); }),
},
}, },
mask: { mask: {
padding: { padding: {
@ -212,107 +260,54 @@ define([
'stroke': '#CC0000', 'stroke': '#CC0000',
}, },
}, },
}; blocks: {
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': { 'ref': {
margin: { margin: {
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
boxRenderer: BaseTheme.renderRef.bind(null, { boxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1.5, 'stroke-width': 1.5,
'rx': 2, 'rx': 2,
'ry': 2, 'ry': 2,
}), }),
section: SHARED_BLOCK_SECTION, section: sharedBlockSection,
}, },
'': { '': {
margin: { margin: {
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1.5, 'stroke-width': 1.5,
'rx': 2, 'rx': 2,
'ry': 2, 'ry': 2,
}), }),
collapsedBoxRenderer: BaseTheme.renderRef.bind(null, { collapsedBoxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1.5, 'stroke-width': 1.5,
'rx': 2, 'rx': 2,
'ry': 2, 'ry': 2,
}), }),
section: SHARED_BLOCK_SECTION, section: sharedBlockSection,
sepRenderer: SVGShapes.renderLine.bind(null, { sepRenderer: svg.lineFactory({
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1.5, 'stroke-width': 1.5,
'stroke-dasharray': '4, 2', 'stroke-dasharray': '4, 2',
}), }),
}, },
}; },
notes: {
const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const NOTES = {
'text': { 'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0}, margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2}, padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10}, overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
}), }),
labelAttrs: NOTE_ATTRS, labelAttrs: NOTE_ATTRS,
@ -321,7 +316,7 @@ define([
margin: {top: 0, left: 5, right: 5, bottom: 0}, margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 5, right: 10, bottom: 5}, padding: {top: 5, left: 5, right: 10, bottom: 5},
overlap: {left: 10, right: 10}, overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderNote.bind(null, { boxRenderer: svg.noteFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -336,7 +331,7 @@ define([
margin: {top: 0, left: 5, right: 5, bottom: 0}, margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 7, left: 7, right: 7, bottom: 7}, padding: {top: 7, left: 7, right: 7, bottom: 7},
overlap: {left: 10, right: 10}, overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -345,16 +340,8 @@ define([
}), }),
labelAttrs: NOTE_ATTRS, labelAttrs: NOTE_ATTRS,
}, },
}; },
dividers: {
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
};
const DIVIDERS = {
'': { '': {
labelAttrs: DIVIDER_LABEL_ATTRS, labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
@ -367,7 +354,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10, extend: 10,
margin: 0, margin: 0,
render: BaseTheme.renderLineDivider.bind(null, { render: this.renderLineDivider.bind(this, {
lineAttrs: { lineAttrs: {
'stroke': '#000000', 'stroke': '#000000',
}, },
@ -378,7 +365,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0, extend: 0,
margin: 0, margin: 0,
render: BaseTheme.renderDelayDivider.bind(null, { render: this.renderDelayDivider.bind(this, {
dotSize: 1, dotSize: 1,
gapSize: 2, gapSize: 2,
}), }),
@ -388,7 +375,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10, extend: 10,
margin: 10, margin: 10,
render: BaseTheme.renderTearDivider.bind(null, { render: this.renderTearDivider.bind(this, {
fadeBegin: 5, fadeBegin: 5,
fadeSize: 10, fadeSize: 10,
zigWidth: 6, zigWidth: 6,
@ -398,17 +385,20 @@ define([
}, },
}), }),
}, },
}; },
return class BasicTheme extends BaseTheme {
constructor() {
super({
name: 'basic',
settings: SETTINGS,
blocks: BLOCKS,
notes: NOTES,
dividers: DIVIDERS,
}); });
} }
}
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'; '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', () => { it('has a name', () => {
expect(theme.name).toEqual('basic'); expect(themeFactory.name).toEqual('basic');
}); });
it('contains settings for the theme', () => { it('contains settings for the theme', () => {

View File

@ -1,12 +1,4 @@
define([ define(['./BaseTheme'], (BaseTheme) => {
'./BaseTheme',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseTheme,
svg,
SVGShapes
) => {
'use strict'; 'use strict';
const FONT = 'sans-serif'; const FONT = 'sans-serif';
@ -14,7 +6,68 @@ define([
const WAVE = new BaseTheme.WavePattern(10, 1); const WAVE = new BaseTheme.WavePattern(10, 1);
const SETTINGS = { const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
};
class ChunkyTheme extends BaseTheme {
constructor(svg) {
super(svg);
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, titleMargin: 12,
outerMargin: 5, outerMargin: 5,
agentMargin: 8, agentMargin: 8,
@ -54,7 +107,7 @@ define([
bottom: 0, bottom: 0,
}, },
arrowBottom: 2 + 14 * 1.3 / 2, arrowBottom: 2 + 14 * 1.3 / 2,
boxRenderer: BaseTheme.renderDB.bind(null, { boxRenderer: this.renderDB.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
@ -70,7 +123,7 @@ define([
}, },
cross: { cross: {
size: 20, size: 20,
render: BaseTheme.renderCross.bind(null, { render: svg.crossFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
@ -79,7 +132,7 @@ define([
}, },
bar: { bar: {
height: 4, height: 4,
render: SVGShapes.renderBox.bind(null, { render: svg.boxFactory({
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
@ -106,8 +159,8 @@ define([
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, null), renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null), renderRev: this.renderRevConnect.bind(this, null),
}, },
'dash': { 'dash': {
attrs: { attrs: {
@ -116,8 +169,8 @@ define([
'stroke-width': 3, 'stroke-width': 3,
'stroke-dasharray': '10, 4', 'stroke-dasharray': '10, 4',
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, null), renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null), renderRev: this.renderRevConnect.bind(this, null),
}, },
'wave': { 'wave': {
attrs: { attrs: {
@ -127,15 +180,15 @@ define([
'stroke-linejoin': 'round', 'stroke-linejoin': 'round',
'stroke-linecap': 'round', 'stroke-linecap': 'round',
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, WAVE), renderFlat: this.renderFlatConnect.bind(this, WAVE),
renderRev: BaseTheme.renderRevConnector.bind(null, WAVE), renderRev: this.renderRevConnect.bind(this, WAVE),
}, },
}, },
arrow: { arrow: {
'single': { 'single': {
width: 10, width: 10,
height: 12, height: 12,
render: BaseTheme.renderArrowHead, render: this.renderArrowHead.bind(this),
attrs: { attrs: {
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
@ -146,7 +199,7 @@ define([
'double': { 'double': {
width: 10, width: 10,
height: 12, height: 12,
render: BaseTheme.renderArrowHead, render: this.renderArrowHead.bind(this),
attrs: { attrs: {
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
@ -158,7 +211,7 @@ define([
'cross': { 'cross': {
short: 10, short: 10,
radius: 5, radius: 5,
render: BaseTheme.renderCross.bind(null, { render: svg.crossFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
@ -184,16 +237,11 @@ define([
}, },
source: { source: {
radius: 5, radius: 5,
render: ({x, y, radius}) => { render: svg.circleFactory({
return svg.make('circle', {
'cx': x,
'cy': y,
'r': radius,
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
}); }),
},
}, },
mask: { mask: {
padding: { padding: {
@ -224,107 +272,54 @@ define([
'stroke': '#DD0000', 'stroke': '#DD0000',
}, },
}, },
}; blocks: {
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': { 'ref': {
margin: { margin: {
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
boxRenderer: BaseTheme.renderRef.bind(null, { boxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 4, 'stroke-width': 4,
'rx': 5, 'rx': 5,
'ry': 5, 'ry': 5,
}), }),
section: SHARED_BLOCK_SECTION, section: sharedBlockSection,
}, },
'': { '': {
margin: { margin: {
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 4, 'stroke-width': 4,
'rx': 5, 'rx': 5,
'ry': 5, 'ry': 5,
}), }),
collapsedBoxRenderer: BaseTheme.renderRef.bind(null, { collapsedBoxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 4, 'stroke-width': 4,
'rx': 5, 'rx': 5,
'ry': 5, 'ry': 5,
}), }),
section: SHARED_BLOCK_SECTION, section: sharedBlockSection,
sepRenderer: SVGShapes.renderLine.bind(null, { sepRenderer: svg.lineFactory({
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
'stroke-dasharray': '5, 3', 'stroke-dasharray': '5, 3',
}), }),
}, },
}; },
notes: {
const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const NOTES = {
'text': { 'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0}, margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2}, padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10}, overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
}), }),
labelAttrs: NOTE_ATTRS, labelAttrs: NOTE_ATTRS,
@ -333,7 +328,7 @@ define([
margin: {top: 0, left: 5, right: 5, bottom: 0}, margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 3, left: 3, right: 10, bottom: 3}, padding: {top: 3, left: 3, right: 10, bottom: 3},
overlap: {left: 10, right: 10}, overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderNote.bind(null, { boxRenderer: svg.noteFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
@ -349,7 +344,7 @@ define([
margin: {top: 0, left: 5, right: 5, bottom: 0}, margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 7, right: 7, bottom: 5}, padding: {top: 5, left: 7, right: 7, bottom: 5},
overlap: {left: 10, right: 10}, overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 3, 'stroke-width': 3,
@ -358,16 +353,8 @@ define([
}), }),
labelAttrs: NOTE_ATTRS, labelAttrs: NOTE_ATTRS,
}, },
}; },
dividers: {
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
};
const DIVIDERS = {
'': { '': {
labelAttrs: DIVIDER_LABEL_ATTRS, labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
@ -380,7 +367,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10, extend: 10,
margin: 0, margin: 0,
render: BaseTheme.renderLineDivider.bind(null, { render: this.renderLineDivider.bind(this, {
lineAttrs: { lineAttrs: {
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
@ -393,7 +380,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0, extend: 0,
margin: 0, margin: 0,
render: BaseTheme.renderDelayDivider.bind(null, { render: this.renderDelayDivider.bind(this, {
dotSize: 3, dotSize: 3,
gapSize: 3, gapSize: 3,
}), }),
@ -403,7 +390,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 10, extend: 10,
margin: 10, margin: 10,
render: BaseTheme.renderTearDivider.bind(null, { render: this.renderTearDivider.bind(this, {
fadeBegin: 5, fadeBegin: 5,
fadeSize: 10, fadeSize: 10,
zigWidth: 6, zigWidth: 6,
@ -415,17 +402,20 @@ define([
}, },
}), }),
}, },
}; },
return class ChunkyTheme extends BaseTheme {
constructor() {
super({
name: 'chunky',
settings: SETTINGS,
blocks: BLOCKS,
notes: NOTES,
dividers: DIVIDERS,
}); });
} }
}
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'; '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', () => { it('has a name', () => {
expect(theme.name).toEqual('chunky'); expect(themeFactory.name).toEqual('chunky');
}); });
it('contains settings for the theme', () => { it('contains settings for the theme', () => {

View File

@ -1,12 +1,4 @@
define([ define(['./BaseTheme'], (BaseTheme) => {
'./BaseTheme',
'svg/SVGUtilities',
'svg/SVGShapes',
], (
BaseTheme,
svg,
SVGShapes
) => {
'use strict'; 'use strict';
const FONT = 'monospace'; const FONT = 'monospace';
@ -23,7 +15,68 @@ define([
+0.25, +0.25,
]); ]);
const SETTINGS = { const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
};
class MonospaceTheme extends BaseTheme {
constructor(svg) {
super(svg);
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, titleMargin: 8,
outerMargin: 4, outerMargin: 4,
agentMargin: 12, agentMargin: 12,
@ -60,7 +113,7 @@ define([
bottom: 3, bottom: 3,
}, },
arrowBottom: 12, arrowBottom: 12,
boxRenderer: BaseTheme.renderDB.bind(null, { boxRenderer: this.renderDB.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -75,7 +128,7 @@ define([
}, },
cross: { cross: {
size: 16, size: 16,
render: BaseTheme.renderCross.bind(null, { render: svg.crossFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -83,7 +136,7 @@ define([
}, },
bar: { bar: {
height: 4, height: 4,
render: SVGShapes.renderBox.bind(null, { render: svg.boxFactory({
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -108,8 +161,8 @@ define([
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, null), renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null), renderRev: this.renderRevConnect.bind(this, null),
}, },
'dash': { 'dash': {
attrs: { attrs: {
@ -118,8 +171,8 @@ define([
'stroke-width': 1, 'stroke-width': 1,
'stroke-dasharray': '4, 4', 'stroke-dasharray': '4, 4',
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, null), renderFlat: this.renderFlatConnect.bind(this, null),
renderRev: BaseTheme.renderRevConnector.bind(null, null), renderRev: this.renderRevConnect.bind(this, null),
}, },
'wave': { 'wave': {
attrs: { attrs: {
@ -127,15 +180,15 @@ define([
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
}, },
renderFlat: BaseTheme.renderFlatConnector.bind(null, WAVE), renderFlat: this.renderFlatConnect.bind(this, WAVE),
renderRev: BaseTheme.renderRevConnector.bind(null, WAVE), renderRev: this.renderRevConnect.bind(this, WAVE),
}, },
}, },
arrow: { arrow: {
'single': { 'single': {
width: 4, width: 4,
height: 8, height: 8,
render: BaseTheme.renderArrowHead, render: this.renderArrowHead.bind(this),
attrs: { attrs: {
'fill': '#000000', 'fill': '#000000',
'stroke-width': 0, 'stroke-width': 0,
@ -145,7 +198,7 @@ define([
'double': { 'double': {
width: 3, width: 3,
height: 6, height: 6,
render: BaseTheme.renderArrowHead, render: this.renderArrowHead.bind(this),
attrs: { attrs: {
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
@ -156,7 +209,7 @@ define([
'cross': { 'cross': {
short: 8, short: 8,
radius: 4, radius: 4,
render: BaseTheme.renderCross.bind(null, { render: svg.crossFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -180,16 +233,11 @@ define([
}, },
source: { source: {
radius: 2, radius: 2,
render: ({x, y, radius}) => { render: svg.circleFactory({
return svg.make('circle', {
'cx': x,
'cy': y,
'r': radius,
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
}); }),
},
}, },
mask: { mask: {
padding: { padding: {
@ -219,101 +267,48 @@ define([
'stroke': '#AA0000', 'stroke': '#AA0000',
}, },
}, },
}; blocks: {
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': { 'ref': {
margin: { margin: {
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
boxRenderer: BaseTheme.renderRef.bind(null, { boxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
}), }),
section: SHARED_BLOCK_SECTION, section: sharedBlockSection,
}, },
'': { '': {
margin: { margin: {
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
}), }),
collapsedBoxRenderer: BaseTheme.renderRef.bind(null, { collapsedBoxRenderer: this.renderRef.bind(this, {
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
}), }),
section: SHARED_BLOCK_SECTION, section: sharedBlockSection,
sepRenderer: SVGShapes.renderLine.bind(null, { sepRenderer: svg.lineFactory({
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 2, 'stroke-width': 2,
'stroke-dasharray': '8, 4', 'stroke-dasharray': '8, 4',
}), }),
}, },
}; },
notes: {
const NOTE_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
};
const NOTES = {
'text': { 'text': {
margin: {top: 0, left: 8, right: 8, bottom: 0}, margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 4, left: 4, right: 4, bottom: 4}, padding: {top: 4, left: 4, right: 4, bottom: 4},
overlap: {left: 8, right: 8}, overlap: {left: 8, right: 8},
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
}), }),
labelAttrs: NOTE_ATTRS, labelAttrs: NOTE_ATTRS,
@ -322,7 +317,7 @@ define([
margin: {top: 0, left: 8, right: 8, bottom: 0}, margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 8, left: 8, right: 8, bottom: 8}, padding: {top: 8, left: 8, right: 8, bottom: 8},
overlap: {left: 8, right: 8}, overlap: {left: 8, right: 8},
boxRenderer: SVGShapes.renderNote.bind(null, { boxRenderer: svg.noteFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -337,7 +332,7 @@ define([
margin: {top: 0, left: 8, right: 8, bottom: 0}, margin: {top: 0, left: 8, right: 8, bottom: 0},
padding: {top: 8, left: 8, right: 8, bottom: 8}, padding: {top: 8, left: 8, right: 8, bottom: 8},
overlap: {left: 8, right: 8}, overlap: {left: 8, right: 8},
boxRenderer: SVGShapes.renderBox.bind(null, { boxRenderer: svg.boxFactory({
'fill': '#FFFFFF', 'fill': '#FFFFFF',
'stroke': '#000000', 'stroke': '#000000',
'stroke-width': 1, 'stroke-width': 1,
@ -346,16 +341,8 @@ define([
}), }),
labelAttrs: NOTE_ATTRS, labelAttrs: NOTE_ATTRS,
}, },
}; },
dividers: {
const DIVIDER_LABEL_ATTRS = {
'font-family': FONT,
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
};
const DIVIDERS = {
'': { '': {
labelAttrs: DIVIDER_LABEL_ATTRS, labelAttrs: DIVIDER_LABEL_ATTRS,
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
@ -368,7 +355,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 8, extend: 8,
margin: 0, margin: 0,
render: BaseTheme.renderLineDivider.bind(null, { render: this.renderLineDivider.bind(this, {
lineAttrs: { lineAttrs: {
'stroke': '#000000', 'stroke': '#000000',
}, },
@ -379,7 +366,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 0, extend: 0,
margin: 0, margin: 0,
render: BaseTheme.renderDelayDivider.bind(null, { render: this.renderDelayDivider.bind(this, {
dotSize: 2, dotSize: 2,
gapSize: 2, gapSize: 2,
}), }),
@ -389,7 +376,7 @@ define([
padding: {top: 2, left: 5, right: 5, bottom: 2}, padding: {top: 2, left: 5, right: 5, bottom: 2},
extend: 8, extend: 8,
margin: 8, margin: 8,
render: BaseTheme.renderTearDivider.bind(null, { render: this.renderTearDivider.bind(this, {
fadeBegin: 4, fadeBegin: 4,
fadeSize: 4, fadeSize: 4,
zigWidth: 4, zigWidth: 4,
@ -399,17 +386,20 @@ define([
}, },
}), }),
}, },
}; },
return class MonospaceTheme extends BaseTheme {
constructor() {
super({
name: 'monospace',
settings: SETTINGS,
blocks: BLOCKS,
notes: NOTES,
dividers: DIVIDERS,
}); });
} }
}
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'; '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', () => { it('has a name', () => {
expect(theme.name).toEqual('monospace'); expect(themeFactory.name).toEqual('monospace');
}); });
it('contains settings for the theme', () => { 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'; 'use strict';
const theme = new SketchTheme(SketchTheme.RIGHT); const svg = new SVG(TestDOM.dom, TestDOM.textSizerFactory);
const themeL = new SketchTheme(SketchTheme.LEFT);
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', () => { it('has a name', () => {
expect(theme.name).toEqual('sketch'); expect(themeFactory.name).toEqual('sketch');
}); });
it('has a left-handed variant', () => { 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', () => { it('contains settings for the theme', () => {
expect(theme.outerMargin).toEqual(5); expect(theme.outerMargin).toEqual(5);
expect(themeL.outerMargin).toEqual(5);
}); });
}); });

View File

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

View File

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