Multiple Panel Example for React/Typescript

Does anyone have a working example for managing multiple plugins in a react/typescript setup?

I tried to include the relevant parts from the ui-react-starter project (PanelController etc), but I keep getting the following error upon plugin load:
image

Also, I couldn’t build/watch the ui-react-starter itself, since it gave me dozens of errors that I couldn’t resolve.

Edit: Here’s the rough setup:

const { entrypoints } = require('uxp');

const rotatorController = new PanelController(() => <Rotator />, { id: "rotator", menuItems: [] });
const shuffleController = new PanelController(() => <Shuffle />, { id: "shuffle", menuItems: [] });

entrypoints.setup({
  plugin: {
    create() {},
    destroy() {}
  },
  panels: {
    rotator: rotatorController,
    shuffle: shuffleController,
  }
});

and the PanelController:

import ReactDOM from "react-dom";

export default class PanelController {
  private id: string;
  private root: HTMLElement;
  private attachment: any;
  private Component: any;
  private menuItems = [];

  constructor(Component, { id, menuItems }: { id: string, menuItems?: any[] }) {
    this.Component = Component;
    this.id = id;
    this.menuItems = (menuItems || []).map(menuItem => ({
      id: menuItem.id,
      label: menuItem.label,
      enabled: menuItem.enabled || true,
      checked: menuItem.checked || false
    }));

    ["create", "show", "hide", "destroy", "invokeMenu"].forEach(fn => this[fn] = this[fn].bind(this));
  }

  public create() {
    this.root = document.createElement("div");
    this.root.style.height = "100vh";
    this.root.style.overflow = "auto";
    this.root.style.padding = "8px";
    ReactDOM.render(this.Component({ panel: this }), this.root);
    return this.root;
  }

  public show(event) {
    if (!this.root) { this.create() };
    this.attachment = event.node;
    this.attachment.appendChild(this.root);
  }

  public hide() {
    if (this.attachment && this.root) {
      this.attachment.removeChild(this.root);
      this.attachment = null;
    }
  }

  public destroy() {
    console.log('destroy')
  }

  public invokeMenu(id) {
    const menuItem = this.menuItems.find(c => c.id === id);
    if (menuItem) {
      const handler = menuItem.oninvoke;
      if (handler) {
        handler();
      }
    }
  }
}

@kerrishotts Maybe you have an idea? I’m not sure how you managed to set the panel values to an instance of the PanelController Class. If I replace the code with something like

entrypoints.setup({
  plugin: {
    create() {},
    destroy() {}
  },
  panels: {
    shuffle: {
      create() { },
      show() { },
      hide() { },
      destroy() { },
      invokeMenu() { },
      menuItems: []
    },
  }
});

… the error goes away, so the panelController instances must be the problem

If you inspect the contents of rotatorController before you pass it to entrypoints.setup, does it look anything like the example that works? The internal code is checking for certain keys, and if it doesn’t see them, it can throw an error, so I’m wondering if something’s getting mangled there. (I’ve not tried this in TS – just in JS, so wondering if the TS compiler is introducing anything new.)

You don’t have to use the PanelController if you don’t want to, either – it was just a convenient abstraction for me. For the sample, make sure you use yarn install, not npm install to get things to work. It’s on my todo list to update the sample so you don’t have to worry, but haven’t got there yet.

Thanks for the hint about yarn install, I was indeed using npm install.

Regarding the controller: I thought that typescript doesn’t add anything at all during runtime, but maybe I was wrong, I don’t know. It just seemed like the entrypoints.setup can’t process any class instances.

I’ve changed some things around to make the controller a normal function, returning an object with the necessary properties:

import ReactDOM from “react-dom”;

export const Controller = (Component, { id, menuItems }: { id: string, menuItems?: any[] }) => {
  let attachment: any
  let root: HTMLElement

  const create = () => {
    console.log('create')
    root = document.createElement("div");
    ReactDOM.render(Component({ panel: this}), root);
    return root;
  }

  return {
    create,
    show: (event) => {
      console.log('Show')
      if (!root) { create() };
      attachment = event.node;
      attachment.appendChild(root);
    },
    hide: () => {
      console.log('hide')
      if (attachment && root) {
        attachment.removeChild(root);
        attachment = null;
      }
    },
    destroy: () => {
      console.log('destroy')
    },
    menuItems,
    invokeMenu: (itemId) => {
      const menuItem = menuItems.find(c => c.id === itemId);
      if (menuItem) {
        const handler = menuItem.oninvoke;
        if (handler) {
          handler();
        }
      }
    }
  }
}

It does at least work now, at least I’m seeing the two panels with their respective content and menuItem invokes work, too.

I’m still left with a few questions though:

  1. Is there any way to define which panel should be opened up when loading the plugin from the UXP Devtools? My plugin will probably include ~8 individual panels, I can imagine that at a later stage it will be quite the pain if a reload always opens up all the panels.

  2. I’m still a but confused about the whole Plugin/Panel lifecycle:
    In my last code example (Controller), the show and create callbacks get fired.
    But what about hide and destroy? I wasn’t able to invoke those, neither my minimizing or closing the panel nor by unloading it from the DevTools.

Also, the callbacks in the entrypoints.setup(), specifically

plugin: {
    create(plugin) {console.log('create', plugin)},
    destroy() {console.log('destroy')}
  }

didn’t get fired. At which point/event does that happen?

  1. Based on 2), I’m wondering what the best practice is for panel states. In my recent plugins, I always save the panelState to a settings.json file when the component updates. I always load these “last settings” when the component gets created. However, this happens only once (when I press load or reload in the DevTools).

Is this even relevant for the user? If the panel is always active in the Photoshop context, there is no reload happening and the last settings are always persistent (without reading / writing from or to a file).

Would be great to get some insights, tips and best practices about that.

I took a look at the internal code, and it’s very picky about what can be in the returned object. If you’ve got anything other than menuItems that isn’t pointing to a function (like create, show, etc.), you’ll get this error. I don’t know what TS is emitting here for private variables, but I assume it’s not doing too much by default, so if those are visible (even as _Component), the validator will fail. (In the sample from the repo, these are using the new private field syntax in JS, which means that the validator can’t see them at all.)

But – doing it with a function is a perfectly fine way too. Given that React has been moving away from classes, it’s probably a good idea to update the sample so that it uses a similar tactic.

To your questions:

  1. Not yet. All panels will be displayed at load, and all panels will show at (0,0). It’s a pain, I know, and we are working on adding some things to the manifest that would allow you to indicate your preferred initial configuration (this would also apply to the first use by a user installing from the marketplace.) I do not have a firm timeline for when this will make it in, but it’s something of which we’re very aware.
  2. hide, destroy were intended to be implemented for MAX 2020, but didn’t get there in time. We’re building those out now. Same for plugin#create and plugin#destroy.
  3. Don’t assume/expect that you’ll be notified when your plugin is going away – a user can always “Force Quit” the app or just turn the machine’s power off. So saving settings when they are changed (vs waiting until the panel is destroyed) is the way to go. Sounds like you’re already doing the right thing here!

For the user, the # of panels is the biggest issue at the moment. Geographic Imager has a similar problem where it opens 6 panels on first launch after installing it. The user must then unstack them and set up their workspace. That’s where the initial layout that we want to add to the manifest will help – you can specify which of your panels should be initially visible, and if they should be grouped together, and roughly where they should appear (though we’re not planning to support x,y coordinates. Hard to do with all the various screen setups out there.)

One thing I want to hone in on as well — don’t assume that your plugin will always be active in the current Ps context. It is currently, but may not be in the future should Ps decide to be more strict about memory management and the like. For example, once you have the other lifecycle methods that you can use, you may want to reload your settings on plugin#create. But you can’t do that just yet – just letting you know for the future.

Thanks alot @kerrishotts , I feel like there are a lot less holes in my UXP knowledge now! :partying_face:

Ah, I was wondering what that weird naming convention with the "#"s was all about :smiley:

Alright, at least there was nothing that I overlook. I don’t think it’s that much of a deal for the user. I assume that so many panels in one plugin will be a rarity after all. And if the user only has to close/arrange them all once (after install), that’s fine IMO. Also, specific XY position doesn’t sound like a super important or even meaningful feature - every user has his own interface setup and also a different device, as you mentioned. That’s not really something I’d want to think about as the developer, everyone can just position the panel as he likes best.

Good point, I’ll keep saving the states then.

1 Like

Pre-release version of Alchemist used 2 panels within same scripting context. It was version 0.12.0 GitHub - jardicc/alchemist at 38d10633fd6794e42af04ea1099831643040cba0 Maybe it could still work :slight_smile: