jupyter-vue
Version:
Jupyter widgets base for Vue libraries
365 lines (341 loc) • 12.9 kB
JavaScript
import { WidgetModel } from '@jupyter-widgets/base';
import uuid4 from 'uuid/v4';
import _ from 'lodash';
import Vue from 'vue';
import { parseComponent } from '@mariobuikhuizen/vue-compiler-addon';
import { createObjectForNestedModel, eventToObject, vueRender } from './VueRenderer'; // eslint-disable-line import/no-cycle
import { VueModel } from './VueModel';
import { VueTemplateModel } from './VueTemplateModel';
import httpVueLoader from './httpVueLoader';
import { TemplateModel } from './Template';
export function vueTemplateRender(createElement, model, parentView) {
return createElement(createComponentObject(model, parentView));
}
function createComponentObject(model, parentView) {
if (model instanceof VueModel) {
return {
render(createElement) {
return vueRender(createElement, model, parentView, {});
},
};
}
if (!(model instanceof VueTemplateModel)) {
return createObjectForNestedModel(model, parentView);
}
const isTemplateModel = model.get('template') instanceof TemplateModel;
const templateModel = isTemplateModel ? model.get('template') : model;
const template = templateModel.get('template');
const vuefile = readVueFile(template);
const css = model.get('css') || (vuefile.STYLE && vuefile.STYLE.content);
const cssId = (vuefile.STYLE && vuefile.STYLE.id);
if (css) {
if (cssId) {
const prefixedCssId = `ipyvue-${cssId}`;
let style = document.getElementById(prefixedCssId);
if (!style) {
style = document.createElement('style');
style.id = prefixedCssId;
document.head.appendChild(style);
}
if (style.innerHTML !== css) {
style.innerHTML = css;
}
} else {
const style = document.createElement('style');
style.id = model.cid;
style.innerHTML = css;
document.head.appendChild(style);
parentView.once('remove', () => {
document.head.removeChild(style);
});
}
}
// eslint-disable-next-line no-new-func
const methods = model.get('methods') ? Function(`return ${model.get('methods').replace('\n', ' ')}`)() : {};
// eslint-disable-next-line no-new-func
const data = model.get('data') ? Function(`return ${model.get('data').replace('\n', ' ')}`)() : {};
const componentEntries = Object.entries(model.get('components') || {});
const instanceComponents = componentEntries.filter(([, v]) => v instanceof WidgetModel);
const classComponents = componentEntries.filter(([, v]) => !(v instanceof WidgetModel) && !(typeof v === 'string'));
const fullVueComponents = componentEntries.filter(([, v]) => typeof v === 'string');
function callVueFn(name, this_) {
if (vuefile.SCRIPT && vuefile.SCRIPT[name]) {
vuefile.SCRIPT[name].bind(this_)();
}
}
return {
data() {
return { ...data, ...createDataMapping(model) };
},
beforeCreate() {
callVueFn('beforeCreate', this);
},
created() {
this.__onTemplateChange = () => {
this.$root.$forceUpdate();
};
templateModel.on('change:template', this.__onTemplateChange);
addModelListeners(model, this);
callVueFn('created', this);
},
watch: createWatches(model, parentView, vuefile.SCRIPT && vuefile.SCRIPT.watch),
methods: {
...vuefile.SCRIPT && vuefile.SCRIPT.methods,
...methods,
...createMethods(model, parentView),
},
components: {
...createInstanceComponents(instanceComponents, parentView),
...createClassComponents(classComponents, model, parentView),
...createFullVueComponents(fullVueComponents),
},
computed: { ...vuefile.SCRIPT && vuefile.SCRIPT.computed, ...aliasRefProps(model) },
template: vuefile.TEMPLATE || template,
beforeMount() {
callVueFn('beforeMount', this);
},
mounted() {
callVueFn('mounted', this);
},
beforeUpdate() {
callVueFn('beforeUpdate', this);
},
updated() {
callVueFn('updated', this);
},
beforeDestroy() {
templateModel.off('change:template', this.__onTemplateChange);
callVueFn('beforeDestroy', this);
},
destroyed() {
callVueFn('destroyed', this);
},
};
}
function createDataMapping(model) {
return model.keys()
.filter(prop => !prop.startsWith('_')
&& !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
.reduce((result, prop) => {
result[prop] = _.cloneDeep(model.get(prop)); // eslint-disable-line no-param-reassign
return result;
}, {});
}
function addModelListeners(model, vueModel) {
model.keys()
.filter(prop => !prop.startsWith('_')
&& !['v_model', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
// eslint-disable-next-line no-param-reassign
.forEach(prop => model.on(`change:${prop}`, () => {
if (_.isEqual(model.get(prop), vueModel[prop])) {
return;
}
vueModel[prop] = _.cloneDeep(model.get(prop));
}));
model.on('msg:custom', (content, buffers) => {
if (!content['method']) {
return;
}
const jupyter_method = 'jupyter_' + content['method'];
if (!vueModel[jupyter_method]) {
return;
}
let args_ = content['args']
if ( args_ == null) {
args_ = []
}
vueModel[jupyter_method](...args_, buffers);
});
}
function createWatches(model, parentView, templateWatchers) {
return model.keys()
.filter(prop => !prop.startsWith('_')
&& !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
.reduce((result, prop) => ({
...result,
[prop]: {
handler(value) {
if (templateWatchers && templateWatchers[prop]) {
templateWatchers[prop].bind(this)(value);
}
/* Don't send changes received from backend back */
if (_.isEqual(value, model.get(prop))) {
return;
}
model.set(prop, value === undefined ? null : _.cloneDeep(value));
model.save_changes(model.callbacks(parentView));
},
deep: true,
},
}), {});
}
function createMethods(model, parentView) {
return model.get('events').reduce((result, event) => {
// eslint-disable-next-line no-param-reassign
result[event] = (value, buffers) => {
if (buffers) {
const validBuffers = buffers instanceof Array &&
buffers[0] instanceof ArrayBuffer;
if (!validBuffers) {
console.warn('second argument is not an BufferArray[View] array')
buffers = undefined;
}
}
model.send(
{event, data: eventToObject(value)},
model.callbacks(parentView),
buffers,
);
}
return result;
}, {});
}
function createInstanceComponents(components, parentView) {
return components.reduce((result, [name, model]) => {
// eslint-disable-next-line no-param-reassign
result[name] = createComponentObject(model, parentView);
return result;
}, {});
}
function createClassComponents(components, containerModel, parentView) {
return components.reduce((accumulator, [componentName, componentSpec]) => ({
...accumulator,
[componentName]: ({
/* TODO: handle naming collisions. Ignore style traitlet for now */
props: componentSpec.props.filter(p => p !== 'style'),
data() {
return {
model: null,
id: uuid4(),
};
},
created() {
const fn = () => {
if (!this.model) {
const newModel = containerModel.get('_component_instances').find(wm => wm.model_id === this.id);
if (newModel) {
this.model = newModel;
}
} else {
containerModel.off('change:_component_instances', fn);
}
};
containerModel.on('change:_component_instances', fn);
containerModel.send(
{
create_widget: componentSpec.class, // eslint-disable-line camelcase
id: this.id,
props: this.$options.propsData,
},
containerModel.callbacks(parentView),
);
},
destroyed() {
containerModel.send(
{
destroy_widget: this.id, // eslint-disable-line camelcase
},
containerModel.callbacks(parentView),
);
},
watch: componentSpec.props.reduce((watchAccumulator, prop) => ({
...watchAccumulator,
[prop](value) {
if (value.objectRef) {
containerModel.send(
{
update_ref: value, // eslint-disable-line camelcase
prop,
id: this.id,
},
containerModel.callbacks(parentView),
);
} else {
this.model.set(prop, value);
this.model.save_changes(this.model.callbacks(parentView));
}
},
}), {}),
render(createElement) {
if (this.model) {
return vueRender(createElement, this.model, parentView, {});
}
return createElement('div', ['temp-content']);
},
}),
}), {});
}
function createFullVueComponents(components) {
return components.reduce((accumulator, [componentName, vueFile]) => ({
...accumulator,
[componentName]: httpVueLoader(vueFile),
}), {});
}
/* Returns a map with computed properties so that myProp_ref is available as myProp in the template
* (only if myProp does not exist).
*/
function aliasRefProps(model) {
return model.keys()
.filter(key => key.endsWith('_ref'))
.map(propRef => [propRef, propRef.substring(0, propRef.length - 4)])
.filter(([, prop]) => !model.keys().includes(prop))
.reduce((accumulator, [propRef, prop]) => ({
...accumulator,
[prop]() {
return this[propRef];
},
}), {});
}
function readVueFile(fileContent) {
const component = parseComponent(fileContent, { pad: 'line' });
const result = {};
if (component.template) {
result.TEMPLATE = component.template.content;
}
if (component.script) {
const { content } = component.script;
const str = content
.substring(content.indexOf('{'), content.length)
.replace('\n', ' ');
// eslint-disable-next-line no-new-func
result.SCRIPT = Function(`return ${str}`)();
}
if (component.styles && component.styles.length > 0) {
const { content } = component.styles[0];
const { id } = component.styles[0].attrs;
result.STYLE = { content, id };
}
return result;
}
Vue.component('jupyter-widget', {
props: ['widget'],
inject: ['viewCtx'],
data() {
return {
component: null,
};
},
created() {
this.update();
},
watch: {
widget() {
this.update();
},
},
methods: {
update() {
this.viewCtx
.getModelById(this.widget.substring(10))
.then((mdl) => {
this.component = createComponentObject(mdl, this.viewCtx.getView());
});
},
},
render(createElement) {
if (!this.component) {
return createElement('div');
}
return createElement(this.component);
},
});