import { getLibrary, getNetworkFromLibrary } from './networkLibrary';
import { Gate } from '../editorElements';
/**
* Item in the [ContextMenu](./module-ContextMenu.html). ContextMenuItems can be nested using the appendItem function.
*/
class ContextMenuItem {
/**
* @param {string} text text on the button
* @param {ContextMenu} contextMenu instance of the [ContextMenu](./module-ContextMenu.html) this item belongs to
* @param {Function} clickFunction callback function that will be called when user clicks this item
*/
constructor(text, contextMenu, clickFunction) {
/**
* text on the button
* @type {string}
*/
this.text = text;
/**
* instance of the [ContextMenu](./module-ContextMenu.html) this item belongs to
* @type {ContextMenu}
*/
this.contextMenu = contextMenu;
/**
* jQuery element representing DOM content of this menu item
* @type {jQuery.element}
*/
this.$el = $('<li>').text(text);
// set up click callback if clickFunction is defined
if (clickFunction !== undefined) {
$(this.$el).click(event => {
clickFunction();
contextMenu.hide();
event.stopPropagation();
});
}
/**
* jQuery element containing the submenu (or undefined, if item has no subitems)
* @type {jQuery.element}
*/
this.$submenu = undefined;
/**
* submenu item counter
* @type {Number}
*/
this.itemCount = 0;
// set hover callback
$(this.$el).hover(
event => {
// mouse on
if (this.length > 0) {
this.$submenu.css({
display: 'block',
top: this.$el.offset().top,
left: this.$el.parent().offset().left + this.$el.parent().width()
});
this.contextMenu.$el.after(this.$submenu);
event.stopPropagation();
}
},
() => {
// mouse out
if (this.$submenu) {
this.$submenu.css({
display: 'none'
});
}
// do not stop event propagation, here it is wanted
// (because submenu overrides display: none when user moves from this menu item to the submenu)
}
);
}
/**
* instance of [App](./module-App.html) this menu belongs to
* @type {App}
*/
get appInstance() {
return this.contextMenu.appInstance;
}
/**
* number of items in the submenu
* @return {Number}
*/
get length() {
return this.itemCount;
}
/**
* add a CSS class to this item
* @param {string} cls [description]
*/
addClass(cls) {
this.$el.addClass(cls);
return this;
}
/**
* append a nested {@link ContextMenuItem} to this item
* @param {ContextMenuItem} item item that will be appended
*/
appendItem(item) {
if (!this.$submenu) {
this.$submenu = $('<ul>').addClass('subList');
this.$submenu.hover(
() => {
this.$submenu.css('display', 'block');
},
() => {
this.$submenu.css('display', 'none');
}
);
}
this.$submenu.append(item.$el);
this.itemCount++;
return item;
}
/**
* get jQuery element of this menu item
* @return {jQuery.element} jQuery element containing all DOM content for this menu item
*/
get jQuery() {
return this.$el;
}
get jQuerySubmenu() {
return this.$submenu;
}
}
/**
* Menu item that has a custom click callback function that adds a {@link Gate} of the specified type to the [App](./module-App.html)
* @extends ContextMenuItem
*/
class GateMenuItem extends ContextMenuItem {
/**
* @param {string} type type of the gate {@link Gate} (and, or, ...)
* @param {ContextMenu} contextMenu instance of the [ContextMenu](./module-ContextMenu.html) that this item belongs to
*/
constructor(type, contextMenu) {
super(`${type.toUpperCase()} gate`, contextMenu, () => {
this.appInstance.newGate(
type,
this.appInstance.snapToGrid(
this.appInstance.viewbox.transformX(contextMenu.position.x)
),
this.appInstance.snapToGrid(
this.appInstance.viewbox.transformY(contextMenu.position.y)
)
);
});
}
}
/**
* Menu item that has a custom click callback function that adds a specified {@link Blackbox} to the [App](./module-App.html)
* @extends ContextMenuItem
*/
class BlackboxMenuItem extends ContextMenuItem {
constructor(name, file, contextMenu) {
super(name, contextMenu, () => {
getNetworkFromLibrary(file)
.then(({ blackbox, name }) => {
const { inputs, outputs, table } = blackbox;
// use the name specified in the blackbox item, if it does not exist, use the name for the network
let usedName = blackbox.name || name;
this.appInstance.newBlackbox(
inputs,
outputs,
table,
usedName,
this.appInstance.snapToGrid(
this.appInstance.viewbox.transformX(contextMenu.position.x)
),
this.appInstance.snapToGrid(
this.appInstance.viewbox.transformY(contextMenu.position.y)
)
);
})
.catch(error => {
console.error(error);
});
});
}
}
/**
* Menu item that has a custom click callback function that adds a specified Network to the [App](./module-App.html)
* @extends ContextMenuItem
*/
class NetworkMenuItem extends ContextMenuItem {
constructor(name, file, contextMenu) {
super(name, contextMenu, () => {
getNetworkFromLibrary(file)
.then(data => {
this.appInstance
.importData(
data,
Math.round(
this.appInstance.viewbox.transformX(contextMenu.position.x) /
this.appInstance.gridSize
),
Math.round(
this.appInstance.viewbox.transformY(contextMenu.position.y) /
this.appInstance.gridSize
)
)
.then(warnings => {
for (const warning of warnings) {
this.appInstance.messages.newWarningMessage(warning);
}
});
})
.catch(error => {
this.appInstance.messages.newErrorMessage(error);
});
});
}
}
/** @module ContextMenu */
/**
* ContextMenu represents the menu that is displayed to the user when they right click on a canvas.
* This menu allows user to add elements to the canvas and in the case that user rightclicked
* on a specific element, this menu allows them to remove this element.
*/
export default class ContextMenu {
/**
* @param {App} appInstance instance of [App](./module-App.html) this menu belongs to
*/
constructor(appInstance) {
/**
* instance of [App](./module-App.html) this menu belongs to
* @type {App}
*/
this.appInstance = appInstance;
/**
* Position of the context menu. It is used to add the new elements to the correct position on the canvas.
* @type {Object}
*/
this.position = {
x: 0,
y: 0
};
/**
* jQuery element containing the context menu
* @type {jQuery.element}
*/
this.$el = $('<ul>');
this.$el.attr('id', 'contextMenu');
let special = new ContextMenuItem('Special elements', this);
// add input box
special.appendItem(
new ContextMenuItem('Input box', this, () => {
let position = {
left: this.appInstance.snapToGrid(
appInstance.viewbox.transformX(this.position.x)
),
top: this.appInstance.snapToGrid(
appInstance.viewbox.transformY(this.position.y)
)
};
appInstance.newInput(position.left, position.top);
})
);
// add output box
special.appendItem(
new ContextMenuItem('Output box', this, () => {
let position = {
left: this.appInstance.snapToGrid(
appInstance.viewbox.transformX(this.position.x)
),
top: this.appInstance.snapToGrid(
appInstance.viewbox.transformY(this.position.y)
)
};
appInstance.newOutput(position.left, position.top);
})
);
this.appendItem(special);
// list of gates that can be added
const gates = Gate.validGates;
let gateList = new ContextMenuItem('New gate', this, appInstance);
for (const name of gates) {
gateList.appendItem(new GateMenuItem(name, this));
}
this.appendItem(gateList);
// more options will be added in the getLibrary() callback below
let networkList = new ContextMenuItem('Add a network', this);
networkList.appendItem(
new ContextMenuItem('Paste a network', this, () => {
this.displayImportDialog();
})
);
this.appendItem(networkList); // always append
let blackboxList = new ContextMenuItem('Add a blackbox', this); // appends only if contains items (see the callback)
// network import (blackbox, network)
getLibrary()
.then(networks => {
for (const { name, file, hasTable, hasNetwork } of networks) {
// add a network as a blackbox
if (hasTable) {
blackboxList.appendItem(new BlackboxMenuItem(name, file, this));
}
// load a network as a network of components connected with wires
if (hasNetwork) {
networkList.appendItem(new NetworkMenuItem(name, file, this));
}
}
if (blackboxList.length > 0) {
this.appendItem(blackboxList);
}
})
.catch(error => {
console.error(error);
});
// add conditional items for box and wire removal
this.appendConditionalItem('box', 'Remove this item', id => {
this.appInstance.removeBox(id);
});
this.appendConditionalItem('wire', 'Remove this wire', id => {
this.appInstance.removeWireById(id);
});
// add the context menu to the DOM
appInstance.$svg.before(this.$el);
/**
* Number of items in this menu (used in the .lenght getter). Conditional items do not count.
* @type {Number}
*/
this.itemCount = 0;
}
get length() {
return this.itemCount;
}
/**
* append a context menu item to the context menu
* @param {ContextMenuItem} item instance of {@link ContextMenuItem} that will be added to this menu
*/
appendItem(item) {
this.$el.append(item.jQuery);
this.itemCount++;
return item;
}
/**
* appends an connditional item (that is shown only if the target has the class itemClass)
* @param {string} itemClass show the item only if the target has this class
* @param {string} text text of this menu item
* @param {Function} clickFunction function with one argument (ID of the target) that will be called on click
*/
appendConditionalItem(itemClass, text, clickFunction) {
if (!this.conditionalItems) {
this.conditionalItems = [];
}
this.conditionalItems[this.conditionalItems.length] = {
itemClass: itemClass,
text: text,
clickFunction: clickFunction
};
}
/**
* display the dialog for importing a network from a clipboard
*/
displayImportDialog() {
let $popup = $('<div>')
.addClass('importExport')
.addClass('import');
let textareaId = 'importJSON';
let $textblock = $('<textarea>').attr('id', textareaId);
let lityInstance;
$popup.append($textblock).append(
$('<a>')
.attr({
href: '#',
class: 'upload'
})
.append($('<img>').attr('src', 'img/gui/import.svg'))
.append(' import from JSON')
.on('click', () => {
let data;
try {
data = JSON.parse($('#' + textareaId).val());
} catch (e) {
this.appInstance.messages.newErrorMessage(
'The imported file is not a valid JSON file.'
);
lityInstance.close();
}
if (data) {
// proccess the imported data
this.appInstance
.importData(
data,
Math.round(
this.appInstance.viewbox.transformX(this.position.x) /
this.appInstance.gridSize
),
Math.round(
this.appInstance.viewbox.transformY(this.position.y) /
this.appInstance.gridSize
)
)
.then(warnings => {
for (const warning of warnings) {
this.appInstance.messages.newWarningMessage(warning);
}
})
.finally(() => {
lityInstance.close();
});
}
})
);
lityInstance = lity($popup);
// focus on the textblock
$textblock.focus();
}
/**
* decide whether or not to display specific conditional items
* @param {jQuery.element} $target jQuery target of a MouseEvent (element that user clicked on)
*/
resolveConditionalItems($target) {
for (let item of this.conditionalItems) {
if ($target.hasClass(item.itemClass)) {
this.appendItem(
new ContextMenuItem(item.text, this, () => {
item.clickFunction($target.attr('id'));
})
).addClass('conditional');
}
}
}
/**
* hide all conditional items
*/
hideAllConditionalItems() {
this.$el.children('.conditional').remove();
}
/**
* displays the context menu with the right set of conditional items
* @param {number} x horizontal position of the context menu in CSS pixels
* @param {number} y vertical position of the context menu in CSS pixels
* @param {jQuery.element} $target jQuery target of a MouseEvent (element that user clicked on)
*/
display(x, y, $target) {
this.position = {
x: x,
y: y
};
this.resolveConditionalItems($target);
this.$el
.css({
display: 'block',
top: y,
left: x
})
// set the width expicitly, or else the menu will widen when displaying a submenu
// 2 is to prevent a weird text wrap bug
.css('width', 'auto')
.css('width', this.$el.innerWidth() + 2);
}
/**
* hide the context menu
*/
hide() {
this.$el.css({ display: 'none' });
$('.subList').css({ display: 'none' });
this.hideAllConditionalItems();
}
}