diff --git a/scripts/core/EventObject.js b/scripts/core/EventObject.js new file mode 100644 index 0000000..28f1182 --- /dev/null +++ b/scripts/core/EventObject.js @@ -0,0 +1,61 @@ +define(() => { + 'use strict'; + + return class EventObject { + constructor() { + this.listeners = new Map(); + this.forwards = new Set(); + } + + addEventListener(type, callback) { + const l = this.listeners.get(type); + if(l) { + l.push(callback); + } else { + this.listeners.set(type, [callback]); + } + } + + removeEventListener(type, fn) { + const l = this.listeners.get(type); + if(!l) { + return; + } + const i = l.indexOf(fn); + if(i !== -1) { + l.splice(i, 1); + } + } + + countEventListeners(type) { + return (this.listeners.get(type) || []).length; + } + + removeAllEventListeners(type) { + if(type) { + this.listeners.delete(type); + } else { + this.listeners.clear(); + } + } + + addEventForwarding(target) { + this.forwards.add(target); + } + + removeEventForwarding(target) { + this.forwards.delete(target); + } + + removeAllEventForwardings() { + this.forwards.clear(); + } + + trigger(type, params = []) { + (this.listeners.get(type) || []).forEach( + (listener) => listener.apply(null, params) + ); + this.forwards.forEach((fwd) => fwd.trigger(type, params)); + } + }; +}); diff --git a/scripts/core/EventObject_spec.js b/scripts/core/EventObject_spec.js new file mode 100644 index 0000000..cc35a6d --- /dev/null +++ b/scripts/core/EventObject_spec.js @@ -0,0 +1,188 @@ +defineDescribe('EventObject', ['./EventObject'], (EventObject) => { + 'use strict'; + + let o = null; + + beforeEach(() => { + o = new EventObject(); + }); + + describe('trigger', () => { + it('invokes registered listeners', () => { + let triggered = 0; + o.addEventListener('foo', () => { + ++ triggered; + }); + + o.trigger('foo'); + + expect(triggered).toEqual(1); + }); + + it('invokes with the given parameters', () => { + let capturedParam1 = null; + let capturedParam2 = null; + o.addEventListener('foo', (param1, param2) => { + capturedParam1 = param1; + capturedParam2 = param2; + }); + + o.trigger('foo', ['a', 'b']); + + expect(capturedParam1).toEqual('a'); + expect(capturedParam2).toEqual('b'); + }); + + it('only invokes relevant callbacks', () => { + let triggered = 0; + o.addEventListener('foo', () => { + ++ triggered; + }); + + o.trigger('bar'); + + expect(triggered).toEqual(0); + }); + + it('forwards to registered objects', () => { + let capturedType = null; + o.addEventForwarding({trigger: (type) => { + capturedType = type; + }}); + + o.trigger('bar'); + + expect(capturedType).toEqual('bar'); + }); + + it('forwards with the given parameters', () => { + let capturedParams = null; + o.addEventForwarding({trigger: (type, params) => { + capturedParams = params; + }}); + + o.trigger('bar', ['a', 'b']); + + expect(capturedParams[0]).toEqual('a'); + expect(capturedParams[1]).toEqual('b'); + }); + }); + + describe('countEventListeners', () => { + it('returns the number of event listeners of a given type', () => { + o.addEventListener('foo', () => {}); + o.addEventListener('foo', () => {}); + expect(o.countEventListeners('foo')).toEqual(2); + }); + + it('does not count unrequested types', () => { + o.addEventListener('foo', () => {}); + o.addEventListener('foo', () => {}); + o.addEventListener('bar', () => {}); + expect(o.countEventListeners('bar')).toEqual(1); + }); + + it('returns 0 for events which have no listeners', () => { + expect(o.countEventListeners('foo')).toEqual(0); + }); + }); + + describe('removeEventListener', () => { + it('removes the requested listener', () => { + let triggered = 0; + const fn = () => { + ++ triggered; + }; + + o.addEventListener('foo', fn); + o.trigger('foo'); + expect(triggered).toEqual(1); + + triggered = 0; + o.removeEventListener('foo', fn); + o.trigger('foo'); + expect(triggered).toEqual(0); + }); + + it('leaves other listeners', () => { + let triggered = 0; + const fn1 = () => { + }; + const fn2 = () => { + ++ triggered; + }; + + o.addEventListener('foo', fn1); + o.addEventListener('foo', fn2); + o.removeEventListener('foo', fn1); + o.trigger('foo'); + expect(triggered).toEqual(1); + }); + + it('leaves other listener types', () => { + let triggered = 0; + const fn = () => { + ++ triggered; + }; + + o.addEventListener('foo', fn); + o.addEventListener('bar', fn); + o.removeEventListener('foo', fn); + o.trigger('bar'); + expect(triggered).toEqual(1); + }); + + it('silently ignores non-existent listeners', () => { + expect(() => o.removeEventListener('foo', () => {})).not.toThrow(); + }); + }); + + describe('removeAllEventListeners', () => { + it('removes all listeners for the requested type', () => { + let triggered = 0; + const fn = () => { + ++ triggered; + }; + + o.addEventListener('foo', fn); + o.trigger('foo'); + expect(triggered).toEqual(1); + + triggered = 0; + o.removeAllEventListeners('foo'); + o.trigger('foo'); + expect(triggered).toEqual(0); + }); + + it('leaves other listener types', () => { + let triggered = 0; + const fn = () => { + ++ triggered; + }; + + o.addEventListener('foo', fn); + o.addEventListener('bar', fn); + o.removeAllEventListeners('foo'); + o.trigger('bar'); + expect(triggered).toEqual(1); + }); + + it('silently ignores non-existent types', () => { + expect(() => o.removeAllEventListeners('foo')).not.toThrow(); + }); + + it('removes all listener types when given no argument', () => { + let triggered = 0; + const fn = () => { + ++ triggered; + }; + + o.addEventListener('foo', fn); + o.addEventListener('bar', fn); + o.removeAllEventListeners(); + o.trigger('foo'); + o.trigger('bar'); + expect(triggered).toEqual(0); + }); + }); +}); diff --git a/scripts/interface/Interface.js b/scripts/interface/Interface.js index 7ab0d19..069d811 100644 --- a/scripts/interface/Interface.js +++ b/scripts/interface/Interface.js @@ -51,6 +51,8 @@ define([ this.pngDirty = true; this.updatingPNG = false; + this.marker = null; + this._downloadSVGClick = this._downloadSVGClick.bind(this); this._downloadPNGClick = this._downloadPNGClick.bind(this); this._downloadPNGFocus = this._downloadPNGFocus.bind(this); @@ -147,6 +149,50 @@ define([ return code; } + registerListeners() { + this.code.on('change', () => this.update(false)); + + this.renderer.addEventListener('mouseover', (element) => { + if(this.marker) { + this.marker.clear(); + } + if(element.ln !== undefined) { + this.marker = this.code.markText( + {line: element.ln, ch: 0}, + {line: element.ln + 1, ch: 0}, + { + className: 'hover', + inclusiveLeft: false, + inclusiveRight: false, + clearOnEnter: true, + } + ); + } + }); + + this.renderer.addEventListener('mouseout', () => { + if(this.marker) { + this.marker.clear(); + this.marker = null; + } + }); + + this.renderer.addEventListener('click', (element) => { + if(this.marker) { + this.marker.clear(); + this.marker = null; + } + if(element.ln !== undefined) { + this.code.setSelection( + {line: element.ln, ch: 0}, + {line: element.ln + 1, ch: 0}, + {origin: '+focus', bias: -1} + ); + this.code.focus(); + } + }); + } + build(container) { const codePane = makeNode('div', {'class': 'pane-code'}); const viewPane = makeNode('div', {'class': 'pane-view'}); @@ -172,7 +218,7 @@ define([ this.code = this.buildEditor(codePane); this.viewPaneInner.appendChild(this.renderer.svg()); - this.code.on('change', () => this.update(false)); + this.registerListeners(); this.update(); } diff --git a/scripts/interface/Interface_spec.js b/scripts/interface/Interface_spec.js index c53a2a0..7af60bd 100644 --- a/scripts/interface/Interface_spec.js +++ b/scripts/interface/Interface_spec.js @@ -31,6 +31,7 @@ defineDescribe('Interface', ['./Interface'], (Interface) => { 'render', 'svg', 'getThemeNames', + 'addEventListener', ]); renderer.svg.and.returnValue(document.createElement('svg')); container = jasmine.createSpyObj('container', ['appendChild']); diff --git a/scripts/main.js b/scripts/main.js index 28797cd..ff3af1d 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -21,6 +21,7 @@ BasicTheme, ChunkyTheme ) => { + /* jshint +W072 */ const defaultCode = ( 'title Labyrinth\n' + '\n' + diff --git a/scripts/readme_images.js b/scripts/readme_images.js index d7ca8ac..1428397 100644 --- a/scripts/readme_images.js +++ b/scripts/readme_images.js @@ -79,6 +79,7 @@ ChunkyTheme, Exporter ) => { + /* jshint +W072 */ const parser = new Parser(); const generator = new Generator(); const themes = [ diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index 1c07dfe..b666490 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -230,6 +230,9 @@ define(['core/ArrayUtilities'], (array) => { if(!stage) { return; } + if(stage.ln === undefined) { + stage.ln = this.latestLine; + } this.currentSection.stages.push(stage); if(isVisible) { this.currentNest.hasContent = true; @@ -244,6 +247,11 @@ define(['core/ArrayUtilities'], (array) => { if(viableStages.length === 1) { return this.addStage(viableStages[0]); } + viableStages.forEach((stage) => { + if(stage.ln === undefined) { + stage.ln = this.latestLine; + } + }); return this.addStage({ type: 'parallel', stages: viableStages, @@ -503,6 +511,7 @@ define(['core/ArrayUtilities'], (array) => { } handleStage(stage) { + this.latestLine = stage.ln; try { this.stageHandlers[stage.type](stage); } catch(e) { diff --git a/scripts/sequence/Generator_spec.js b/scripts/sequence/Generator_spec.js index 78ba5bd..00a3dd4 100644 --- a/scripts/sequence/Generator_spec.js +++ b/scripts/sequence/Generator_spec.js @@ -26,26 +26,29 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { return {type: 'block end'}; }, - defineAgents: (agentNames) => { + defineAgents: (agentNames, {ln = 0} = {}) => { return { type: 'agent define', agents: makeParsedAgents(agentNames), + ln, }; }, - beginAgents: (agentNames, {mode = 'box'} = {}) => { + beginAgents: (agentNames, {mode = 'box', ln = 0} = {}) => { return { type: 'agent begin', agents: makeParsedAgents(agentNames), mode, + ln, }; }, - endAgents: (agentNames, {mode = 'cross'} = {}) => { + endAgents: (agentNames, {mode = 'cross', ln = 0} = {}) => { return { type: 'agent end', agents: makeParsedAgents(agentNames), mode, + ln, }; }, @@ -54,6 +57,7 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { line = '', left = 0, right = 0, + ln = 0, } = {}) => { return { type: 'connect', @@ -64,18 +68,21 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { left, right, }, + ln, }; }, note: (type, agentNames, { mode = '', label = '', + ln = 0, } = {}) => { return { type, agents: makeParsedAgents(agentNames), mode, label, + ln, }; }, }; @@ -83,21 +90,25 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { const GENERATED = { beginAgents: (agentNames, { mode = jasmine.anything(), + ln = jasmine.anything(), } = {}) => { return { type: 'agent begin', agentNames, mode, + ln, }; }, endAgents: (agentNames, { mode = jasmine.anything(), + ln = jasmine.anything(), } = {}) => { return { type: 'agent end', agentNames, mode, + ln, }; }, @@ -106,6 +117,7 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { line = jasmine.anything(), left = jasmine.anything(), right = jasmine.anything(), + ln = jasmine.anything(), } = {}) => { return { type: 'connect', @@ -116,33 +128,42 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { left, right, }, + ln, }; }, - highlight: (agentNames, highlighted) => { + highlight: (agentNames, highlighted, { + ln = jasmine.anything(), + } = {}) => { return { type: 'agent highlight', agentNames, highlighted, + ln, }; }, note: (type, agentNames, { mode = jasmine.anything(), label = jasmine.anything(), + ln = jasmine.anything(), } = {}) => { return { type, agentNames, mode, label, + ln, }; }, - parallel: (stages) => { + parallel: (stages, { + ln = jasmine.anything(), + } = {}) => { return { type: 'parallel', stages, + ln, }; }, }; @@ -172,14 +193,14 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { it('passes marks and async through', () => { const sequence = generator.generate({stages: [ - {type: 'mark', name: 'foo'}, - {type: 'async', target: 'foo'}, - {type: 'async', target: ''}, + {type: 'mark', name: 'foo', ln: 0}, + {type: 'async', target: 'foo', ln: 1}, + {type: 'async', target: '', ln: 2}, ]}); expect(sequence.stages).toEqual([ - {type: 'mark', name: 'foo'}, - {type: 'async', target: 'foo'}, - {type: 'async', target: ''}, + {type: 'mark', name: 'foo', ln: 0}, + {type: 'async', target: 'foo', ln: 1}, + {type: 'async', target: '', ln: 2}, ]); }); diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index e1918ee..bc2b1df 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -1,5 +1,7 @@ +/* jshint -W072 */ // Allow several required modules define([ 'core/ArrayUtilities', + 'core/EventObject', 'svg/SVGUtilities', 'svg/SVGShapes', './components/BaseComponent', @@ -10,10 +12,12 @@ define([ './components/Note', ], ( array, + EventObject, svg, SVGShapes, BaseComponent ) => { + /* jshint +W072 */ 'use strict'; function traverse(stages, callbacks) { @@ -77,13 +81,23 @@ define([ let globalNamespace = 0; - return class Renderer { + function parseNamespace(namespace) { + if(namespace === null) { + namespace = 'R' + globalNamespace; + ++ globalNamespace; + } + return namespace; + } + + return class Renderer extends EventObject { constructor({ themes = [], namespace = null, components = null, SVGTextBlockClass = SVGShapes.TextBlock, } = {}) { + super(); + if(components === null) { components = BaseComponent.getComponents(); } @@ -111,11 +125,7 @@ define([ this.height = 0; this.themes = makeThemes(themes); this.theme = null; - this.namespace = namespace; - if(namespace === null) { - this.namespace = 'R' + globalNamespace; - ++ globalNamespace; - } + this.namespace = parseNamespace(namespace); this.components = components; this.SVGTextBlockClass = SVGTextBlockClass; this.knownDefs = new Set(); @@ -442,6 +452,29 @@ define([ }; let bottomY = topY; stages.forEach((stage) => { + const eventOver = () => { + this.trigger('mouseover', [stage]); + }; + + const eventOut = () => { + this.trigger('mouseout'); + }; + + const eventClick = () => { + this.trigger('click', [stage]); + }; + + env.makeRegion = (o) => { + if(!o) { + o = svg.make('g'); + } + o.addEventListener('mouseenter', eventOver); + o.addEventListener('mouseleave', eventOut); + o.addEventListener('click', eventClick); + this.actionLabels.appendChild(o); + return o; + }; + const component = this.components.get(stage.type); const baseY = component.render(stage, env); if(baseY !== undefined) { diff --git a/scripts/sequence/components/AgentCap.js b/scripts/sequence/components/AgentCap.js index 067579d..e098923 100644 --- a/scripts/sequence/components/AgentCap.js +++ b/scripts/sequence/components/AgentCap.js @@ -39,16 +39,24 @@ define([ render(y, {x, label}, env) { const config = env.theme.agentCap.box; - const {height} = SVGShapes.renderBoxedText(label, { + const clickable = env.makeRegion(); + const {width, height} = SVGShapes.renderBoxedText(label, { x, y, padding: config.padding, boxAttrs: config.boxAttrs, labelAttrs: config.labelAttrs, boxLayer: env.shapeLayer, - labelLayer: env.labelLayer, + labelLayer: clickable, SVGTextBlockClass: env.SVGTextBlockClass, }); + clickable.insertBefore(svg.make('rect', { + 'x': x - width / 2, + 'y': y, + 'width': width, + 'height': height, + 'fill': 'transparent', + }), clickable.firstChild); return { lineTop: 0, diff --git a/scripts/sequence/components/BaseComponent.js b/scripts/sequence/components/BaseComponent.js index c41976a..26d14bb 100644 --- a/scripts/sequence/components/BaseComponent.js +++ b/scripts/sequence/components/BaseComponent.js @@ -47,6 +47,7 @@ define(() => { textSizer, SVGTextBlockClass, addDef, + makeRegion, state, }*/) { } diff --git a/scripts/sequence/components/Connect.js b/scripts/sequence/components/Connect.js index 7c91930..a6c7639 100644 --- a/scripts/sequence/components/Connect.js +++ b/scripts/sequence/components/Connect.js @@ -135,6 +135,7 @@ define([ } renderSelfConnect({label, agentNames, options}, env) { + /* jshint -W071 */ // TODO: find appropriate abstractions const config = env.theme.connect; const from = env.agentInfos.get(agentNames[0]); @@ -155,6 +156,8 @@ define([ (label ? config.label.padding : 0) ); + const clickable = env.makeRegion(); + const renderedText = SVGShapes.renderBoxedText(label, { x: x0 - config.mask.padding.left, y: y0 - height + config.label.margin.top, @@ -162,7 +165,7 @@ define([ boxAttrs: {'fill': '#000000'}, labelAttrs: config.label.loopbackAttrs, boxLayer: env.maskLayer, - labelLayer: env.labelLayer, + labelLayer: clickable, SVGTextBlockClass: env.SVGTextBlockClass, }); const labelW = (label ? ( @@ -190,7 +193,17 @@ define([ lArrow.render(env.shapeLayer, env.theme, {x: lineX, y: y0, dir: 1}); rArrow.render(env.shapeLayer, env.theme, {x: lineX, y: y1, dir: 1}); - return y1 + rArrow.height(env.theme) / 2 + env.theme.actionMargin; + const arrowDip = rArrow.height(env.theme) / 2; + + clickable.insertBefore(svg.make('rect', { + 'x': lineX, + 'y': y0 - height, + 'width': x1 + r - lineX, + 'height': height + r * 2 + arrowDip, + 'fill': 'transparent', + }), clickable.firstChild); + + return y1 + arrowDip + env.theme.actionMargin; } renderSimpleConnect({label, agentNames, options}, env) { @@ -211,7 +224,9 @@ define([ const x0 = from.x + from.currentMaxRad * dir; const x1 = to.x - to.currentMaxRad * dir; - let y = env.primaryY; + const y = env.primaryY; + + const clickable = env.makeRegion(); SVGShapes.renderBoxedText(label, { x: (x0 + x1) / 2, @@ -220,7 +235,7 @@ define([ boxAttrs: {'fill': '#000000'}, labelAttrs: config.label.attrs, boxLayer: env.maskLayer, - labelLayer: env.labelLayer, + labelLayer: clickable, SVGTextBlockClass: env.SVGTextBlockClass, }); @@ -235,14 +250,20 @@ define([ lArrow.render(env.shapeLayer, env.theme, {x: x0, y, dir}); rArrow.render(env.shapeLayer, env.theme, {x: x1, y, dir: -dir}); - return ( - y + - Math.max( - lArrow.height(env.theme), - rArrow.height(env.theme) - ) / 2 + - env.theme.actionMargin - ); + const arrowDip = Math.max( + lArrow.height(env.theme), + rArrow.height(env.theme) + ) / 2; + + clickable.insertBefore(svg.make('rect', { + 'x': Math.min(x0, x1), + 'y': y - height, + 'width': Math.abs(x1 - x0), + 'height': height + arrowDip, + 'fill': 'transparent', + }), clickable.firstChild); + + return y + arrowDip + env.theme.actionMargin; } renderPre({label, agentNames, options}, env) { diff --git a/scripts/sequence/components/Note.js b/scripts/sequence/components/Note.js index 742c8e1..277a27c 100644 --- a/scripts/sequence/components/Note.js +++ b/scripts/sequence/components/Note.js @@ -1,4 +1,4 @@ -define(['./BaseComponent'], (BaseComponent) => { +define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => { 'use strict'; function findExtremes(agentInfos, agentNames) { @@ -34,8 +34,10 @@ define(['./BaseComponent'], (BaseComponent) => { }, env) { const config = env.theme.note[mode]; + const clickable = env.makeRegion(); + const y = env.topY + config.margin.top + config.padding.top; - const labelNode = new env.SVGTextBlockClass(env.labelLayer, { + const labelNode = new env.SVGTextBlockClass(clickable, { attrs: config.labelAttrs, text: label, y, @@ -84,6 +86,14 @@ define(['./BaseComponent'], (BaseComponent) => { height: fullH, })); + clickable.insertBefore(svg.make('rect', { + 'x': x0, + 'y': env.topY + config.margin.top, + 'width': x1 - x0, + 'height': fullH, + 'fill': 'transparent', + }), clickable.firstChild); + return ( env.topY + config.margin.top + diff --git a/scripts/sequence/sequence_integration_spec.js b/scripts/sequence/sequence_integration_spec.js index 21c5e1c..a48c721 100644 --- a/scripts/sequence/sequence_integration_spec.js +++ b/scripts/sequence/sequence_integration_spec.js @@ -1,4 +1,4 @@ -/* jshint -W072 */ +/* jshint -W072 */ // Allow several required modules defineDescribe('Sequence Integration', [ './Parser', './Generator', @@ -12,6 +12,7 @@ defineDescribe('Sequence Integration', [ BasicTheme, SVGTextBlock ) => { + /* jshint +W072 */ 'use strict'; let parser = null; diff --git a/scripts/specs.js b/scripts/specs.js index 2aa5688..115576b 100644 --- a/scripts/specs.js +++ b/scripts/specs.js @@ -1,5 +1,6 @@ define([ 'core/ArrayUtilities_spec', + 'core/EventObject_spec', 'svg/SVGUtilities_spec', 'svg/SVGTextBlock_spec', 'svg/SVGShapes_spec', diff --git a/styles/main.css b/styles/main.css index de582a2..8e32757 100644 --- a/styles/main.css +++ b/styles/main.css @@ -34,6 +34,10 @@ html, body { background: rgba(255, 0, 0, 0.2); } +.hover { + background: #FFFF00; +} + .pick-virtual { color: #777777; }