Swap windows for tabs

Each ImJoy window now gets its own top-level tab.
This involved some fairly significant refactoring of the ImJoy
component, but I think it's cleaner now.
I've moved imjoy-related stuff into a store "module" to keep it clean.
This commit is contained in:
Richard 2021-05-13 17:10:36 +01:00
parent 0e16835e1b
commit 7585e24744
6 changed files with 224 additions and 157 deletions

View file

@ -60,6 +60,26 @@
src="https://imjoy.io/static/img/imjoy-icon.svg" src="https://imjoy.io/static/img/imjoy-icon.svg"
/> />
</tabIcon> </tabIcon>
<tabIcon
v-for="imjoyTab in imjoyTabs"
:key="imjoyTab.id"
:tab-i-d="'ImJoy-Plugin-' + imjoyTab.id"
:title="imjoyTab.name"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
<img
v-if="imjoyTab.iconURL"
style="filter: grayscale(100%);width: 22px;margin-top: 5px;margin-bottom: 8px;"
:src="imjoyTab.iconURL"
/>
<i v-if="!imjoyTab.iconURL" class="material-icons">
{{ imjoyTab.iconName || "extension" }}
</i>
</tabIcon>
<hr id="extension-tab-divider" /> <hr id="extension-tab-divider" />
<!-- For each bottom tab --> <!-- For each bottom tab -->
@ -124,6 +144,16 @@
<ImJoyContent /> <ImJoyContent />
</tabContent> </tabContent>
<tabContent
v-for="imjoyTab in imjoyTabs"
:key="imjoyTab.id"
:tab-i-d="'ImJoy-Plugin-' + imjoyTab.id"
:require-connection="false"
:current-tab="currentTab"
>
<div :id="imjoyTab.window_id" class="window-container">Loading...</div>
</tabContent>
<!-- For each bottom tab --> <!-- For each bottom tab -->
<tabContent <tabContent
v-for="item in bottomTabs" v-for="item in bottomTabs"
@ -141,6 +171,7 @@
<script> <script>
import axios from "axios"; import axios from "axios";
import { mapState } from "vuex";
// Import generic components // Import generic components
import tabIcon from "./genericComponents/tabIcon"; import tabIcon from "./genericComponents/tabIcon";
@ -264,7 +295,9 @@ export default {
currentTabIndex: function() { currentTabIndex: function() {
return this.tabOrder.indexOf(this.currentTab); return this.tabOrder.indexOf(this.currentTab);
} },
...mapState("imjoy", { imjoyTabs: "tabs" })
}, },
created: function() { created: function() {
@ -370,6 +403,10 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.window-container {
width: 100%;
height: 100%;
}
#component-left { #component-left {
width: 100%; width: 100%;
height: 100%; height: 100%;

View file

@ -44,7 +44,11 @@
</div> </div>
<div class="uk-card-footer uk-padding-small"> <div class="uk-card-footer uk-padding-small">
<button class="uk-icon" type="button"> <button
v-if="openInImjoyMenuItems.length != 0"
class="uk-icon"
type="button"
>
<img <img
style="width:25px;" style="width:25px;"
src="https://imjoy.io/static/img/imjoy-icon.svg" src="https://imjoy.io/static/img/imjoy-icon.svg"
@ -163,6 +167,7 @@
<script> <script>
import UIkit from "uikit"; import UIkit from "uikit";
import axios from "axios"; import axios from "axios";
import { mapState } from "vuex";
import keyvalList from "../../fieldComponents/keyvalList"; import keyvalList from "../../fieldComponents/keyvalList";
@ -223,9 +228,6 @@ export default {
}, },
computed: { computed: {
openInImjoyMenuItems: function() {
return this.$store.state.openInImjoyMenuItems;
},
tagModalID: function() { tagModalID: function() {
return this.makeModalName("tag-modal-"); return this.makeModalName("tag-modal-");
}, },
@ -252,7 +254,8 @@ export default {
}, },
captureURL: function() { captureURL: function() {
return this.links.self.href; return this.links.self.href;
} },
...mapState("imjoy", { openInImjoyMenuItems: "openImageMenu" })
}, },
created: function() {}, created: function() {},

View file

@ -41,7 +41,7 @@
<div class="uk-card-footer uk-padding-small"> <div class="uk-card-footer uk-padding-small">
<button <button
v-if="openScanInImjoyMenuItemsPresent" v-if="openInImjoyMenuItems.length != 0"
class="uk-icon" class="uk-icon"
type="button" type="button"
> >
@ -52,7 +52,7 @@
</button> </button>
<div uk-dropdown="pos: top-center"> <div uk-dropdown="pos: top-center">
<ul class="uk-nav uk-dropdown-nav"> <ul class="uk-nav uk-dropdown-nav">
<li v-for="item in openScanInImjoyMenuItems" :key="item.name"> <li v-for="item in openInImjoyMenuItems" :key="item.name">
<a <a
href="#" href="#"
style="color:black" style="color:black"
@ -125,10 +125,7 @@ export default {
} }
return urls; return urls;
}, },
openScanInImjoyMenuItemsPresent: function() { ...mapState("imjoy", { openInImjoyMenuItems: "openScanMenu" })
return this.openScanInImjoyMenuItems.length > 0;
},
...mapState(["openScanInImjoyMenuItems"])
}, },
methods: { methods: {

View file

@ -0,0 +1,48 @@
<template>
<div
class="uk-card uk-card-hover uk-card-default uk-width-medium"
:class="{ 'uk-card-secondary': $store.state.darkMode }"
>
<a href="#" class="uk-link-text" @click="$emit('click')">
<h3 class="uk-card-header">{{ name }}</h3>
<div class="uk-card-body">
<div class="uk-align-center">
<img
v-if="iconURL"
class="uk-width-1-1"
:data-src="iconURL"
:alt="name + ' icon'"
uk-img
/>
<i v-if="!iconURL" class="material-icons" style="font-size:96px">
{{ iconName }}
</i>
</div>
<slot></slot>
</div>
</a>
</div>
</template>
<script>
export default {
name: "ImjoyPluginCard",
props: {
name: {
type: String,
required: true
},
iconURL: {
type: String,
required: false,
default: () => ""
},
iconName: {
type: String,
required: false,
default: () => "extension"
}
}
};
</script>

View file

@ -7,83 +7,31 @@
class="uk-progress" class="uk-progress"
max="100" max="100"
></progress> ></progress>
<button
v-if="activeWindow"
class="uk-button uk-button-default close-button"
type="button"
@click="closeActiveWindow()"
>
<i class="material-icons">close</i>
</button>
<button class="uk-button uk-button-default plugins-button" type="button">
<img
style="width:18px;"
src="https://imjoy.io/static/img/imjoy-icon.svg"
/>&nbsp;Plugins
</button>
<div uk-dropdown>
<ul class="uk-nav uk-dropdown-nav">
<li>
<a href="#" @click="loadPluginDialog()">
<i class="material-icons">add</i>&nbsp; Load Plugin</a
>
</li>
<li
v-if="Object.keys(loadedPlugins).length > 0"
class="uk-nav-divider"
></li>
<li>
<a href="#" @click="startImageJ()"
><img
alt="ImageJ.JS icon"
style="width:20px;"
src="https://ij.imjoy.io/assets/icons/chrome/chrome-installprocess-128-128.png"
/>&nbsp;ImageJ.JS</a
>
</li>
<li>
<a href="#" @click="startKaibu()"
><img
alt="Kaibu icon"
style="width:20px;"
src="https://kaibu.org/static/img/kaibu-icon.svg"
/>&nbsp;Kaibu</a
>
</li>
<li>
<a
v-for="(p, name) in loadedPlugins"
:key="p.id"
href="#"
@click="runPlugin(p)"
><i class="material-icons">extension</i>{{ name }}</a
>
</li>
<li class="uk-nav-divider"></li>
<li>
<a v-for="w in windows" :key="w.id" href="#" @click="activeWindow = w"
><i class="material-icons">filter_none</i>&nbsp;{{ w.name }}</a
>
</li>
</ul>
</div>
<div <div class="uk-child-width-1-3@m uk-grid-match" uk-grid>
v-for="w in windows" <imjoy-plugin-card
v-show="w === activeWindow" name="New"
:key="w.id" icon-u-r-l="https://imjoy.io/static/img/imjoy-icon.svg"
style="height: 100%;" @click="loadPluginDialog()"
> >
<h4 class="window-title">{{ w.name }}</h4> Load plugin from URL
<div :id="w.window_id" class="window-container"></div> </imjoy-plugin-card>
</div> <imjoy-plugin-card
<div name="ImageJ.JS"
v-if="!activeWindow || windows.length <= 0" icon-u-r-l="https://ij.imjoy.io/assets/icons/chrome/chrome-installprocess-128-128.png"
class="uk-position-center position-relative text-center" @click="openImageJ()"
> />
<img <imjoy-plugin-card
style="width:80px;" name="Kaibu"
src="https://imjoy.io/static/img/imjoy-icon.svg" icon-u-r-l="https://kaibu.org/static/img/kaibu-icon.svg"
@click="startKaibu()"
/>
<imjoy-plugin-card
v-for="(p, name) in loadedPlugins"
:key="p.id"
:name="name"
icon-name="extension"
@click="runPlugin(p)"
/> />
</div> </div>
@ -97,6 +45,8 @@
import * as imjoyCore from "imjoy-core"; import * as imjoyCore from "imjoy-core";
import axios from "axios"; import axios from "axios";
import UIkit from "uikit"; import UIkit from "uikit";
import { mapState } from "vuex";
import ImjoyPluginCard from "./imjoyComponents/imjoyPluginCard.vue";
// TODO: move this to a module or something... // TODO: move this to a module or something...
async function pollAction(response) { async function pollAction(response) {
@ -174,13 +124,11 @@ async function imageUrlToNdarray(url) {
export default { export default {
name: "ImJoyContent", name: "ImJoyContent",
components: {}, components: { ImjoyPluginCard },
data() { data() {
return { return {
windows: [],
loading: false, loading: false,
loadedPlugins: {}, loadedPlugins: {},
activeWindow: null,
viewers: {} viewers: {}
}; };
}, },
@ -199,7 +147,8 @@ export default {
}, },
baseUri: function() { baseUri: function() {
return this.$store.getters.baseUri; return this.$store.getters.baseUri;
} },
...mapState("imjoy", { windows: "tabs" })
}, },
mounted() { mounted() {
const self = this; const self = this;
@ -405,28 +354,31 @@ export default {
}; };
this.imjoy.pm.registerService({ name: "openflexure" }, service); this.imjoy.pm.registerService({ name: "openflexure" }, service);
}, },
closeActiveWindow() { closeWindow(w) {
if (this.activeWindow) { if (w) {
this.activeWindow.api.close(); w.api.close();
const index = this.windows.indexOf(this.activeWindow); this.$store.commit("imjoy/removeTab", w);
if (index > -1) {
this.windows.splice(index, 1);
}
if (this.windows.length > 0) {
this.activeWindow = this.windows[this.windows.length - 1];
} else {
this.activeWindow = null;
}
} }
}, },
selectWindow(name) { /**
* Activate a window - you can pass the window object, its name, or its id.
*/
selectWindow(window) {
for (let w of this.windows) { for (let w of this.windows) {
if (w.name === name) { if ((w === window) | (w.name === window) | (w.id === window)) {
this.activeWindow = w; // Make the relevant window visible by switching to its tab
this.$root.$emit("globalSwitchTab", `ImJoy-Plugin-${w.id}`);
break; break;
} }
} }
}, },
setWindowIconUrl(window, iconURL) {
this.$store.commit("imjoy/setTabProperty", {
tab: window,
key: "iconURL",
value: iconURL
});
},
async startKaibu() { async startKaibu() {
this.viewers.kaibu = await this.imjoy.pm.createWindow(null, { this.viewers.kaibu = await this.imjoy.pm.createWindow(null, {
name: "Kaibu", name: "Kaibu",
@ -435,8 +387,11 @@ export default {
this.viewers.kaibu.on("close", () => { this.viewers.kaibu.on("close", () => {
delete this.viewers.kaibu; delete this.viewers.kaibu;
}); });
this.setWindowIconUrl(
"Kaibu",
"https://kaibu.org/static/img/kaibu-icon.svg"
);
this.viewers.kaibu.set_mode("full"); this.viewers.kaibu.set_mode("full");
this.selectWindow("Kaibu");
}, },
async startImageJ() { async startImageJ() {
this.viewers.imagej = await this.imjoy.pm.createWindow(null, { this.viewers.imagej = await this.imjoy.pm.createWindow(null, {
@ -446,17 +401,19 @@ export default {
this.viewers.imagej.on("close", () => { this.viewers.imagej.on("close", () => {
delete this.viewers.imagej; delete this.viewers.imagej;
}); });
this.setWindowIconUrl(
"ImageJ.JS",
"https://ij.imjoy.io/assets/icons/chrome/chrome-installprocess-128-128.png"
);
// load the ITK/VTK viewer for imagej.js // load the ITK/VTK viewer for imagej.js
this.loadPlugin( this.loadPlugin(
"https://gist.githubusercontent.com/oeway/e5c980fbf6582f25fde795262a7e33ec/raw/itk-vtk-viewer-imagej.imjoy.html" "https://gist.githubusercontent.com/oeway/e5c980fbf6582f25fde795262a7e33ec/raw/itk-vtk-viewer-imagej.imjoy.html"
); );
this.selectWindow("ImageJ.JS");
}, },
/** /**
* Switch to the ImageJ window, starting it if necessary. * Switch to the ImageJ window, starting it if necessary.
*/ */
async openImageJ() { async openImageJ() {
this.$root.$emit("globalSwitchTab", "ImJoy");
if (!this.viewers.imagej) { if (!this.viewers.imagej) {
await this.startImageJ(); await this.startImageJ();
} }
@ -467,11 +424,10 @@ export default {
* Switch to the Kaibu window, starting it if necessary. * Switch to the Kaibu window, starting it if necessary.
*/ */
async openKaibu() { async openKaibu() {
this.$root.$emit("globalSwitchTab", "ImJoy");
if (!this.viewers.kaibu) { if (!this.viewers.kaibu) {
await this.startKaibu(); await this.startKaibu();
} }
this.selectWindow("Kaibu"); this.selectWindow("Kaibu"); // TODO: make this behave more nicely when we have multiple Kaibu windows
return this.viewers.kaibu; return this.viewers.kaibu;
}, },
async setupViewers() { async setupViewers() {
@ -485,16 +441,7 @@ export default {
try { try {
const self = this; const self = this;
/*async function obtainImageJ() { this.$store.commit("imjoy/addOpenImageItem", {
self.$root.$emit("globalSwitchTab", "ImJoy");
if(!self.viewers.imagej) {
await self.startImageJ();
}
self.selectWindow("ImageJ.JS");
return self.viewers.imagej;
}*/
this.$store.commit("addOpenInImjoyMenuItem", {
title: "Kaibu", title: "Kaibu",
async callback(name, imageUrl) { async callback(name, imageUrl) {
const viewer = await self.openKaibu(); const viewer = await self.openKaibu();
@ -503,7 +450,7 @@ export default {
} }
}); });
this.$store.commit("addOpenInImjoyMenuItem", { this.$store.commit("imjoy/addOpenImageItem", {
title: "ImageJ.JS", title: "ImageJ.JS",
async callback(name, imageUrl) { async callback(name, imageUrl) {
const viewer = await self.openImageJ(); const viewer = await self.openImageJ();
@ -514,7 +461,7 @@ export default {
}); });
} }
}); });
this.$store.commit("addOpenInImjoyMenuItem", { this.$store.commit("imjoy/addOpenImageItem", {
title: "ImageJ.JS [ndarray]", title: "ImageJ.JS [ndarray]",
async callback(name, imageUrl) { async callback(name, imageUrl) {
const viewer = await self.openImageJ(); const viewer = await self.openImageJ();
@ -524,7 +471,7 @@ export default {
}); });
} }
}); });
this.$store.commit("addOpenScanInImjoyMenuItem", { this.$store.commit("imjoy/addOpenScanItem", {
title: "ImageJ.JS", title: "ImageJ.JS",
async callback(name, allURLs) { async callback(name, allURLs) {
const viewer = await self.openImageJ(); const viewer = await self.openImageJ();
@ -566,7 +513,7 @@ export default {
}); });
} }
}); });
this.$store.commit("addOpenScanInImjoyMenuItem", { this.$store.commit("imjoy/addOpenScanItem", {
title: "ImageJ.JS [JPEGs]", title: "ImageJ.JS [JPEGs]",
async callback(name, allURLs) { async callback(name, allURLs) {
const viewer = await self.openImageJ(); const viewer = await self.openImageJ();
@ -592,11 +539,11 @@ export default {
this.loading = false; this.loading = false;
} }
}, },
addWindow(w) { async addWindow(w) {
this.windows = this.windows || []; this.$store.commit("imjoy/addTab", w);
this.windows.push(w); await this.$nextTick();
this.activeWindow = w; //this.$forceUpdate();
this.$forceUpdate(); this.selectWindow(w);
}, },
loadPlugin(uri) { loadPlugin(uri) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -629,22 +576,7 @@ export default {
if (uri) { if (uri) {
this.loadPlugin(uri); this.loadPlugin(uri);
} }
} /* },
async getPosition() {
const pos = await service.getXYZPosition();
return pos.z;
},
async setXYPosition(x, y) {
return service.setXYZPosition({
"x": x,
"y": y,
"absolute": true,
});
},
async getXYPosition() {
const pos = await service.getXYZPosition();
return [pos.x, pos.y];
},*/,
async getStagePosition() { async getStagePosition() {
return axios.get(this.getPositionUri).then(r => r.data); return axios.get(this.getPositionUri).then(r => r.data);
}, },

View file

@ -3,7 +3,65 @@ import Vuex from "vuex";
Vue.use(Vuex); Vue.use(Vuex);
const moduleImjoy = {
namespaced: true,
state: () => ({
tabs: [],
openImageMenu: [],
openScanMenu: []
}),
mutations: {
addOpenImageItem(state, newItem) {
state.openImageMenu.push(newItem);
}, // TODO: add a mutation to remove items when plugins are unloaded
addOpenScanItem(state, newItem) {
state.openScanMenu.push(newItem);
}, // TODO: add a mutation to remove items when plugins are unloaded
clearMenus(state) {
// This is primarily useful to reset the state in hot-reloads of
// imjoyContent.vue
state.openImageMenu = [];
state.openScanMenu = [];
},
addTab(state, newItem) {
// Add a tab to the list of ImJoy tabs
state.tabs.push(newItem);
},
removeTab(state, tabToRemove) {
const index = state.tabs.indexOf(tabToRemove);
if (index > -1) {
state.tabs.splice(index, 1);
} else {
console.warn(
`Attempted to remove a non-existing ImJoy tab ${tabToRemove}`
);
}
},
/**
* Set a parameter on any matching tab objects
*/
setTabProperty(state, payload) {
let tab = payload.tab;
let key = payload.key;
let value = payload.value;
for (let i = 0; i < state.tabs.length; i++) {
let t = state.tabs[i];
if ((t === tab) | (t.name === tab) | (t.id === tab)) {
t[key] = value;
}
state.tabs.splice(i, 1, t); // This should work nicely with reactive stuff
}
//state.tabs = tablist; // This should force an update
}
},
actions: {},
getters: {}
};
export default new Vuex.Store({ export default new Vuex.Store({
modules: {
imjoy: moduleImjoy
},
state: { state: {
origin: window.location.origin, origin: window.location.origin,
available: false, available: false,
@ -14,9 +72,7 @@ export default new Vuex.Store({
trackWindow: true, trackWindow: true,
IHIEnabled: false, IHIEnabled: false,
appTheme: "system", appTheme: "system",
activeStreams: {}, activeStreams: {}
openInImjoyMenuItems: [],
openScanInImjoyMenuItems: []
}, },
mutations: { mutations: {
@ -58,13 +114,7 @@ export default new Vuex.Store({
}, },
removeStream(state, id) { removeStream(state, id) {
state.activeStreams[id] = false; state.activeStreams[id] = false;
}, }
addOpenInImjoyMenuItem(state, newItem) {
state.openInImjoyMenuItems.push(newItem);
}, // TODO: add a mutation to remove items when plugins are unloaded
addOpenScanInImjoyMenuItem(state, newItem) {
state.openScanInImjoyMenuItems.push(newItem);
} // TODO: add a mutation to remove items when plugins are unloaded
}, },
actions: {}, actions: {},