/**
* The Vue application instance.
* https://vuejs.org/guide/essentials/application.html
* This is the root component
*/
const app = Vue.createApp({
/**
* to prevent conflict with jekyll we change the
* delimiters from {{ }} to [[ ]]
*/
delimiters: ['[[', ']]'],
/**
* The data object of the root component.
* These variables are available in the html template.
*/
data() {
return {
// state of the editor; used for undo
state: {
// The rule object
rule: global.rule || {
ruleID: "",
timestamp: "",
icon: "",
name: "",
lineage: "",
summary: "",
config: {},
creator: {
name: "",
url: "",
},
modules: []
},
// the data of the current module in the editor
editor: {
source: null,
previousState: null,
module: null,
},
},
legacy: false,
rID: false,
loading: false,
publishing: false,
view: (global.rule) ? true : false,
preview: (global.rule) ? true : false,
template: (global.rule) ? true : false,
steinAPI: 'https://api.steinhq.com/v1/storages/5e8b937ab88d3d04ae0816a5',
// keep an array of past states for undo/redo
currentStateIndex: -1,
history: [],
// the module that is currently being dragged
holding: false,
// object of icons for easy access
icons: {
plus: '/assets/tabler_icons/circle-plus.svg',
info: '/assets/tabler_icons/info-circle.svg',
chevron: '/assets/tabler_icons/chevrons-down.svg',
blank: '/assets/tabler_icons/circle-dotted.svg',
view: '/assets/tabler_icons/eye.svg',
edit: '/assets/tabler_icons/tool.svg',
plus: '/assets/tabler_icons/plus.svg',
minus: '/assets/tabler_icons/minus.svg',
publish: '/assets/tabler_icons/cloud-upload.svg',
download: '/assets/tabler_icons/download.svg',
export: '/assets/tabler_icons/file-download.svg',
upload: '/assets/tabler_icons/file-upload.svg',
fork: '/assets/tabler_icons/git-fork.svg',
},
// icons available in the editor
moduleIcons: {
culture: '/assets/tabler_icons/palette.svg',
decision: '/assets/tabler_icons/thumb-up.svg',
process: '/assets/tabler_icons/rotate.svg',
structure: '/assets/tabler_icons/building.svg',
relationship: '/assets/tabler_icons/heart.svg',
economic: '/assets/tabler_icons/coin.svg',
legal: '/assets/tabler_icons/license.svg',
map: '/assets/tabler_icons/map.svg',
communications: '/assets/tabler_icons/microphone.svg',
},
// icons available for rules
ruleIcons: {
atom: '/assets/tabler_icons/atom.svg',
bandage: '/assets/tabler_icons/bandage.svg',
book: '/assets/tabler_icons/book.svg',
box: '/assets/tabler_icons/box.svg',
church: '/assets/tabler_icons/building-church.svg',
store: '/assets/tabler_icons/building-store.svg',
brush: '/assets/tabler_icons/brush.svg',
car: '/assets/tabler_icons/car.svg',
clock: '/assets/tabler_icons/clock.svg',
cloud: '/assets/tabler_icons/cloud.svg',
compass: '/assets/tabler_icons/compass.svg',
game: '/assets/tabler_icons/device-gamepad.svg',
flask: '/assets/tabler_icons/flask.svg',
location: '/assets/tabler_icons/location.svg',
moon: '/assets/tabler_icons/moon.svg',
settings: '/assets/tabler_icons/settings.svg',
shield: '/assets/tabler_icons/shield.svg',
star: '/assets/tabler_icons/star.svg',
tool: '/assets/tabler_icons/tool.svg',
world: '/assets/tabler_icons/world.svg',
},
// the template for modules
moduleTemplate: {
moduleID: "",
name: "",
icon: "",
summary: "",
config: {},
type: "",
modules: []
},
// tracks the current module library tab that is open
moduleLibrary: 'culture',
moduleTypes: {
// custom: {
// question: 'Modules that you\'ve created.',
// icon: '/assets/tabler_icons/circle-plus.svg',
// open: true
// },
culture: {
question: 'What are the core missions, values, and norms?',
icon: '/assets/tabler_icons/palette.svg',
open: true
},
decision: {
question: 'Who can make decisions and how?',
icon: '/assets/tabler_icons/thumb-up.svg',
open: false
},
process: {
question: 'How are policies implemented, and how do they evolve?',
icon: '/assets/tabler_icons/rotate.svg ',
open: false
},
structure: {
question: 'What kinds of roles and internal entities are there?',
icon: '/assets/tabler_icons/building.svg',
open: false
}
},
// array of modules that have been created by the user
// TODO: implement custom modules
customModules: [],
// library of existing modules
modules: global.modules,
exports: {
markdown: null,
json: null,
}
}
},
// on load
mounted() {
// put initial state into this.history
this.initializeUndoFunctionality()
},
/**
* Vue provide passes data to other components.
* https://vuejs.org/guide/components/provide-inject.html#provide-inject
*/
provide() {
return {
editor: this.state.editor,
icons: this.icons,
}
},
created() {
this.addToEditor(this.newModule());
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('r')) {
this.rID = urlParams.get('r');
this.preview = true;
this.view = true;
this.fetchRule(this.rID);
}
},
computed: {
/**
* Exports the current rule into a normalized format
* Cleans up all submodules so they are ready for export
* @returns {Object} a Rule object
*/
ruleExport() {
//TODO: test if icon is an absolute url and only add the global if it is not
/**
* Takes a module and recursively cleans it and all submodules up
* @param {Object} module a module object
* @returns
*/
function cleanModules(module) {
const newModule = {
moduleID: module.moduleID,
name: module.name,
icon: (module.icon && !module.icon.includes('http')) ? global.url + module.icon : module.icon,
summary: module.summary,
config: module.config,
type: module.type,
modules: (module.modules) ? module.modules.map(cleanModules) : [],
}
return newModule;
}
return {
ruleID: (this.state.rule.ruleID) ? this.state.rule.ruleID : this.slugify(this.state.rule.name),
timestamp: this.timesString(),
icon: (this.state.rule.icon && !this.state.rule.icon.includes('http')) ? global.url + this.state.rule.icon : this.state.rule.icon,
name: this.state.rule.name,
lineage: this.state.rule.lineage,
summary: this.state.rule.summary,
config: this.state.rule.config,
creator: {
name: this.state.rule.creator.name,
url: this.state.rule.creator.url,
},
modules: this.state.rule.modules.map(cleanModules)
}
},
/**
* @returns {String} the current rule as a JSON string
*/
json() {
return JSON.stringify(this.ruleExport, null, 2);
},
/**
* Creates an array of all moduleIDs in use
* @returns {Array} an array of all module's (in the library and custom modules) moduleID
*/
listModuleIds() {
const modules = [...this.state.rule.modules, ...this.customModules];
return modules.map(module => module.moduleID)
},
/**
* @returns {Object} the current module in the editor
*/
moduleInEditor() {
return this.moduleEditor[0]
},
/**
* Tests if the current module in the editor has been modified
* against the editor.previousState object
* @returns {Boolean} true if the module in the editor has been modified
*/
//TODO: find a more accurate solution than just turning the object into a string
editorHasEdits() {
return this.state.editor.module && Object.entries(this.state.editor.module).toString() !== Object.entries(this.state.editor.previousState).toString();
},
/**
* required to update editor on state updates
*/
computedState() {
// state of the editor; used for undo
return this.state;
}
},
methods: {
// module methods ===========================================================
/**
* @returns {Object} a new module object from the moduleTemplate
*/
newModule() {
return JSON.parse(JSON.stringify(this.moduleTemplate));
},
/**
* spreads a source module into a new module from the moduleTemplate
* @param {Object} sourceModule the module to copy
* @param {Boolean} includeSubmodules whether to copy submodules or not
* @returns
*/
cloneModule(sourceModule,includeSubmodules) {
let output = {
...this.moduleTemplate,
...sourceModule,
//TODO: implement lineage pattern, same as the rule does
source: sourceModule, // keep track of where this module came from
};
if (!includeSubmodules) output.modules = [];
// clear configs
document.getElementById("newConfigKey").value =
"Configuration";
document.getElementById("newConfigValue").value =
"Value";
// delete unnecessary properties
delete output.content;
delete output.readonly;
// TODO: give module a unique id
// if (output.moduleID) output.moduleID = this.getUniqueId(output.moduleID);
return output;
},
/**
* Handles the click event to copy a module
* @param {Event} ev the click event
*/
handleClickCopyModule(ev) {
const clickTarget = this.getClosestModule(ev.target);
if (!clickTarget) return;
this.copyModule(clickTarget.module);
},
/**
* Handles the click event to edit a module
* @param {Event} ev the click event
*/
handleClickEditModule(ev) {
const clickTarget = this.getClosestModule(ev.target);
if (!clickTarget) return;
this.editModule(clickTarget.module);
},
/**
* Copies a module to the editor
* @param {Object} module the module to copy
*/
copyModule(module) {
this.copyToEditor(module);
},
/**
* moves a module to the editor
* @param {Object} module the module to edit
*/
editModule(module) {
this.addToEditor(module);
},
/**
* add a module to another module (or to the rule by default) as a submodule
* @param {Object} module to add
* @param {Object} target a module or the rule Object where the module should be added as a submodule
*/
addModule(module,target = this.state.rule) {
target.modules.push(module);
},
/**
* remove a module from another module (or from the rule)
* recursively moves through all submodules in the target
* removes ONLY the first instance of the module
* @param {Object} module the module to remove from target
* @param {Object} target the module or rule to remove the module from (defaults to rule)
*/
removeModule(module, target = this.state.rule) {
if (! this.moduleContains(module, target)) return; // if the module is not in the target, do nothing
//
target.modules.forEach((m, idx) => {
if (m === module) {
target.modules.splice(idx, 1);
return;
} else {
this.removeModule(module, m);
}
});
},
/**
* Deletes custom module from the customModules array and clears the editor
* @param {Object} module the module to be deleted
*/
deleteModule(module) {
this.removeCustomModule(module);
// TODO: only clear the editor if the module is present in the editor
this.clearEditor();
},
/**
* Handles the start drag event for a module
* @param {Event} ev the drag event
*/
startDragModule(ev) {
const targetModule = this.getClosestModule(ev.target);
if (!targetModule) return;
const module = targetModule.module;
ev.dataTransfer.setDragImage(targetModule, ev.offsetX, ev.offsetY);
this.holding = {module};
},
/**
* Handles the start drag event for a module
* when the module is being rearranged within the rule
* @param {Event} ev the drag event
*/
rearrangeModule(ev) {
const targetModule = this.getClosestModule(ev.target);
if (!targetModule) return;
const source = this.getClosestModule(targetModule.parentNode).module;
const module = targetModule.module;
ev.dataTransfer.setDragImage(targetModule, ev.offsetX, ev.offsetY);
this.holding = {
module,
source,
};
},
/**
* Handles the dragend event for a module
*/
endDragModule() {
this.holding = false;
},
/**
* Recursively searches modules and their submodules for a module
* @param {Object} needle the module to search for
* @param {Object} haystack the module to search in (defaults to rule)
* @returns {Boolean} true if the module is in the haystack
*/
// TODO: return the module location in the haystack (Maybe?)
moduleContains(needle, haystack = this.state.rule) {
if (! haystack.modules ) return false; // does the haystack even have modules?
if (haystack.modules.includes(needle)) return true; // is the needle in the haystack?
return haystack.modules.some(submodule => this.moduleContains(needle, submodule)); // is the needle in any of the submodules?
},
// rule methods ===========================================================
/**
* Handles the drop event for a module
* adds the module to the closest submodule or the rule depending on what it is dropped onto
* then adds the module to the editor
* @param {Event} ev the drop event
*/
dropOnRule(ev) {
//TODO browser drag objects that hover over drop zone are showing a 'add' icon
const landingNode = this.getClosestModule(ev.target);
if (!this.holding.module || !landingNode) return; // if there is no module to drop or no landing node, do nothing
const landingModule = landingNode.module; // module is set with the v-bind prop binding
const holdingModule = this.holding.module;
if (holdingModule === landingModule) return; // if the module is the same, do nothing
// if the module being dropped is readyonly clone it, otherwise use the original
const readonly = holdingModule.readonly;
const module = (readonly) ? this.cloneModule(holdingModule) : holdingModule;
if (this.holding.source) {
// if the module has a source, remove it from that source
this.removeModule(holdingModule, this.holding.source);
}
this.addModule(module, landingModule);
this.editModule(module);
this.endDragModule();
},
fetchRule(id) {
this.loading = true;
// handle legacy links
// TODO: handle this at a global level
let redirect = {
'benevolent_dictator': 'benevolent-dictator',
circles: 'circles',
consensus: 'consensus',
'do-ocracy': 'do-ocracy',
'elected_board': 'elected-board',
jury: 'jury',
petition: 'petition',
'self-appointed_board': 'self-appointed-board',
}
// if the rule is a legacy link, redirect
if (redirect[id]) {
location.href = `/templates/${redirect[id]}`;
return;
}
const store = new SteinStore(
this.steinAPI
);
(async () => {
var rule = [];
// read values from all sheets
await store.read('rules', { search: { ruleID: id } }).then(data => {
// test if there's anything in data
if (data.length > 0) {
rule = data[0];
}
console.log(rule);
});
// no rule found, exit
// TODO: inform the user that the rule was not found
if (!rule) {
this.loading = false;
return;
}
// if this is a legacy (pre-v3) set it as such
if (rule.version < 3) {
this.loading = false;
this.legacy = true;
this.state.rule = rule;
Vue.nextTick(() => {
if (rule.version == 2) displayBuilderHTML();
});
return;
}
this.state.rule = {
ruleID: rule.ruleID,
timestamp: rule.timestamp,
icon: rule.icon,
name: rule.name,
lineage: rule.lineage,
summary: rule.summary,
config: rule.config,
creator: {
name: rule.creatorName,
url: rule.creatorUrl,
},
modules: (rule.modules) ? JSON.parse(rule.modules) : []
}
/** Add name to
for v3+ rules */
document.title = rule.name + " / CommunityRule"
this.loading = false;
})();
},
// editor methods =========================================================
/**
* Adds a module to the editor
* @param {Object} module the module to add to the editor
*/
addToEditor(module) {
this.preventEditorLoss();
this.setEditorSource(module);
this.setEditorPreviousState(module);
this.state.editor.module = module;
},
/**
* Copies a module to the editor
* @param {Object} module the module to copy to the editor
*/
copyToEditor(module) {
const moduleCopy = this.cloneModule(module);
this.preventEditorLoss();
this.setEditorSource(module);
this.setEditorPreviousState(moduleCopy);
this.state.editor.module = moduleCopy;
},
/**
* Takes a module and clones it into the editor.previousState
* @param {Object} module the module to add to the previous state
*/
setEditorPreviousState(module) {
this.state.editor.previousState = { ...module };
},
/**
* Sets the editor.source to the module
* @param {Object} module the module to set the editor source to
*/
setEditorSource(module) {
this.state.editor.source = module;
},
/**
* Checks if the editor has edits and that the current module in the editor is not present in the rule
* If the module in the editor would be lost, confirm with the user
* then adds the module to the customModules array
*/
preventEditorLoss() {
// if the editor has changes and the module isn't in the rule, save it to the custom modules
if (this.state.editorHasEdits && !this.moduleContains(this.state.editor.module)) {
this.confirm('You have unsaved changes. Are you sure you want to discard them?')
this.addCustomModule(this.state.editor.module);
}
},
/**
* Handles the click event for adding a module from the editor to the rule
*/
clickAddModule() {
const module = this.state.editor.module;
this.addModule(module);
this.addToEditor(module);
},
/**
* Handles the click event for removing a module in the editor from the rule
*/
clickRemoveModule() {
const module = this.state.editor.module;
this.removeModule(module);
},
/**
* Clears the editor
*/
clearEditor() {
this.preventEditorLoss();
this.state.editor.module = null;
this.state.editor.previousState = null;
},
/**
* Saves the module in the editor to customModules array
*/
saveEditor() {
this.addCustomModule(this.state.editor.module);
this.setEditorPreviousState(this.state.editor.module);
},
// config methods =========================================================
/**
* Add custom config entry to the module
*/
addConfig() {
const k = document.getElementById("newConfigKey").value;
const v = document.getElementById("newConfigValue").value;
this.state.editor.module.config[k] = v;
this.resetNewConfigInputs();
},
/**
* Removes the config entry from the module
*/
removeConfig(key) {
delete this.state.editor.module.config[key];
},
// custom module methods ==================================================
/**
* Adds a module to the customModules array
* @param {Object} module the module to add to the customModules array
*/
addCustomModule(module) {
// if module is not in customModules, add it
if (!this.customModules.includes(module)) {
this.customModules.unshift(module);
}
},
/**
* Creates a new module, sets a default name, and adds it to the editor
*/
newCustomModule() {
const module = this.newModule();
module.name = 'New Module';
module.config = {};
this.resetNewConfigInputs();
this.addToEditor(module);
},
resetNewConfigInputs() {
document.getElementById("newConfigKey").value = "Configuration";
document.getElementById("newConfigValue").value = "Value";
},
/**
* Removes a module from the customModules array
* @param {Object} module the module to remove from the customModules array
*/
removeCustomModule(module) {
this.confirm("are you sure you want to remove this custom module?");
const index = this.customModules.indexOf(module);
this.customModules.splice(index, 1);
},
/**
* Handles confirmation messages for users
* @param {String} msg the message to display in the confirm dialog
*/
// TODO: add a confirm dialog and return boolean based on user input
confirm(msg) {
console.log(msg);
},
// export and download methods =============================================
/**
* Handles click event for publishing the rule
*/
handleClickPublish() {
// Confirm user knows what they're getting into
if (!confirm("Publish to the public Library?")) return;
if ( !this.state.rule.name ) {
alert("Please enter a name for this rule.");
return;
}
if ( this.state.rule.modules.length < 1 ) {
alert("Please add at least one module to this rule.");
return;
}
this.publishing = true;
const rule = this.ruleExport;
const ruleID = new Date().getTime(); // TODO: allow for custom named IDs, check for uniqueness
// add to database
const store = new SteinStore(
this.steinAPI
);
store.append('rules', [{
ruleID: ruleID,
timestamp: rule.timestamp,
icon: rule.icon,
name: rule.name,
lineage: rule.lineage,
summary: rule.summary,
config: this.jsonify(rule.config),
modules: this.jsonify(rule.modules),
creatorName: rule.creator.name,
creatorUrl: rule.creator.url,
version: 3
}]).then(data => {
this.publishing = false;
window.open("/create/?r=" + ruleID, "_self", false);
});
},
/**
* Handles the click event for downloading the rule as a Markdown file
* Creates a YAML string of the rule
* Then adds it to the bottom of a markdown file
* created from the #rule-export html element
*/
handleClickDownload() {
const yaml = jsyaml.dump(this.ruleExport);
const turndown = new TurndownService();
const html = document.getElementById('rule-export');
if (!html) return;
const markdown = turndown.turndown(html);
const output = markdown + '\n\n----\n```yaml\n' + yaml + '\n```';
this.saveFile(`${this.ruleExport.ruleID}.md`, output, 'text/markdown');
},
/**
* IE10+ Firefox, and Chrome method for saving a file
* https://stackoverflow.com/a/33542499
* @param {String} filename name of the file to save
* @param {String} data data to save into a file
* @param {String} type MIME type of the file
*/
saveFile(filename, data, type) {
const blob = new Blob([data], { type: type });
if (window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveBlob(blob, filename);
}
else {
const elem = window.document.createElement('a');
elem.href = window.URL.createObjectURL(blob);
elem.download = filename;
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
}
},
/**
* Handles the click event for importing a rule from a JSON file
*/
handleClickImport() {
},
handleStateUpdate()
{
const MAX_UNDOS_ALLOWED = 15;
this.currentStateIndex += 1;
this.history[this.currentStateIndex] = JSON.parse(JSON.stringify(this.state));
// if we have >1 states in the redo stack (i.e. history past our current index)
if (this.currentStateIndex != this.history.length - 1)
{
// truncate history, so we can't redo out of order.
this.history.length = this.currentStateIndex + 1; // truncates forked redo states
}
while (this.history.length > MAX_UNDOS_ALLOWED)
{
this.history.shift();
this.currentStateIndex -= 1;
}
},
canUndo()
{
return this.currentStateIndex > 0;
},
/**
* handles undo by shifting the state back one index in the history
*/
handleUndo()
{
this.appComponent.focus();
if (this.canUndo())
{
this.currentStateIndex -= 1;
this.state = this.history[this.currentStateIndex];
}
else
{
console.warn('No more actions to undo!')
}
},
canRedo()
{
return this.currentStateIndex < this.history.length - 1;
},
/**
* handles redo by shifting the currentStateIndex forward
*/
handleRedo()
{
this.appComponent.focus();
if (this.canRedo())
{
this.currentStateIndex += 1;
this.state = this.history[this.currentStateIndex]
}
else
{
console.warn('No more actions to redo!')
}
},
/**
* initializes undo capabilities
*/
initializeUndoFunctionality()
{
this.appComponent = document.getElementById('app'); // todo this doesn't work yet
this.handleStateUpdate();
const component = this;
document.addEventListener('keydown', function (event)
{
console.log('keypress event:')
console.log(event)
console.log('ctrl?', event.ctrlKey)
if (component.getModifierKeyByOs(event) && event.key === 'z')
{
event.preventDefault();
if (event.shiftKey)
{
component.handleRedo();
}
else
{
component.handleUndo();
}
}
});
},
/**
* the modifier key is different for mac vs windows.
* this should (but does not yet) return
* event.ctrlKey if os == windows
* event.metaKey if os == mac
* @param {*} event
* @returns bool: true if the os-specific modifier key is depressed
*/
getModifierKeyByOs(event) {
// TODO: this allows ctrl+z on a mac to undo, which isn't normal.
// it might also allow windows key + z to undo on windows.
return (event.metaKey || event.ctrlKey)
},
// utility methods ===========================================================
/**
* Takes an html element and finds the closest node (inclusive) that has a data-module-id attribute
* @param {Node} el the html element to check
* @returns {Node} the closest node with a data-module-id attribute
*/
getClosestModule(el) {
const parent = el.closest('[data-module-id]');
if (!parent) return false;
if (!parent.module) return false;
return parent;
},
/**
* Handles the click event for activating the rule preview
*/
clickPreview() {
if(this.template) this.state.rule.icon = ''; // TODO: find a less hacky way to reset template icons
this.view = false;
this.preview = !this.preview;
},
/**
* Filters module library based on the search term
* @param {String} type the name of the type to filter
* @returns {Array} the filtered module library
*/
getModulesByType(type) {
return this.modules.filter(module => module.type === type)
},
/**
* Slugifies a string
* https://gist.github.com/codeguy/6684588 (one of the comments)
* @param {String} string the string to slugify
* @returns
*/
slugify(string) {
const separator = '-';
return string
.toString()
.normalize('NFD') // split an accented letter in the base letter and the accent
.replace(/[\u0300-\u036f]/g, '') // remove all previously split accents
.toLowerCase()
.replace(/[^a-z0-9 -]/g, '') // remove all chars not letters, numbers and spaces (to be replaced)
.trim()
.replace(/\s+/g, separator);
},
/**
* Creates a human readable timestamp string
* @param {String} date optional date to format
* @returns {String} human readable date '2022.4.12 14:44:56 UTC'
*/
timesString(date) {
let now = new Date(date);
if (isNaN(now)) now = new Date();
return now.getUTCFullYear() + '.' + (now.getUTCMonth() + 1) + '.' + now.getUTCDate()
+ ' ' + now.getUTCHours() + ":" + now.getUTCMinutes() + ":" + now.getUTCSeconds()
+ ' UTC';
},
/**
* stringify an Object
* @param {Object} obj
* @returns JSON string
*/
jsonify(obj) {
return JSON.stringify(obj, null, 2);
},
/**
* Takes a moduleID and checks if that moduleID is in use
* if so, returns the moduleID with a number appended to it
* @param {String} test the moduleID to test
* @returns {String} the moduleID, or if in use, with a number appended to it
*/
getUniqueId(test) {
let id = test;
let i = 0;
while (this.listModuleIds.includes(id)) {
i++;
id = `${test}-${i}`;
}
return id
},
},
});
/**
* The Module Vue Component
*/
app.component('module', {
delimiters: ['[[', ']]'],
inject: ['editor'],
props: {
module: {
type: Object,
required: true,
},
inEditor: {
type: Boolean,
default: false,
},
hideSubmodules: {
type: Boolean,
default: false,
}
},
data() {
return {
defaultIcon: '/assets/tabler_icons/circle-dotted.svg',
mouseOver: false,
dragOver: false
}
},
computed: {
icon() {
return this.module.icon ? this.module.icon : this.defaultIcon;
},
moduleClass() {
return {
'in-editor': this.editor.source == this.module,
'mouse-over': this.mouseOver,
// TODO: when dragging over the icon the drag-over class disappears
'drag-over': this.dragOver
}
}
},
template: `