Select code in editor when elements are clicked [#16]

This commit is contained in:
David Evans 2017-11-10 23:38:28 +00:00
parent b562120c33
commit 25ffd6a904
16 changed files with 442 additions and 35 deletions

View File

@ -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));
}
};
});

View File

@ -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);
});
});
});

View File

@ -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();
}

View File

@ -31,6 +31,7 @@ defineDescribe('Interface', ['./Interface'], (Interface) => {
'render',
'svg',
'getThemeNames',
'addEventListener',
]);
renderer.svg.and.returnValue(document.createElement('svg'));
container = jasmine.createSpyObj('container', ['appendChild']);

View File

@ -21,6 +21,7 @@
BasicTheme,
ChunkyTheme
) => {
/* jshint +W072 */
const defaultCode = (
'title Labyrinth\n' +
'\n' +

View File

@ -79,6 +79,7 @@
ChunkyTheme,
Exporter
) => {
/* jshint +W072 */
const parser = new Parser();
const generator = new Generator();
const themes = [

View File

@ -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) {

View File

@ -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},
]);
});

View File

@ -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) {

View File

@ -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,

View File

@ -47,6 +47,7 @@ define(() => {
textSizer,
SVGTextBlockClass,
addDef,
makeRegion,
state,
}*/) {
}

View File

@ -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) {

View File

@ -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 +

View File

@ -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;

View File

@ -1,5 +1,6 @@
define([
'core/ArrayUtilities_spec',
'core/EventObject_spec',
'svg/SVGUtilities_spec',
'svg/SVGTextBlock_spec',
'svg/SVGShapes_spec',

View File

@ -34,6 +34,10 @@ html, body {
background: rgba(255, 0, 0, 0.2);
}
.hover {
background: #FFFF00;
}
.pick-virtual {
color: #777777;
}