feat(plugins): compiled-plugin support — expose React/ReactDOM/jsx-runtime/Fragment as externalization targets, add settings_section slot (addSettingsSection) wired into Settings, + plugin-template/ (Vite build with React externalized) and docs
This commit is contained in:
parent
0032471faf
commit
71473bd478
13 changed files with 279 additions and 16 deletions
|
|
@ -49,7 +49,7 @@ bound to the app's React) — runtime template markup, no build step. Prefer
|
|||
| `Orbit.plugin(name, fn)` | register a plugin; `fn(orbit, log)` |
|
||||
| `Orbit.on/once/off/emit(event, …)` | the app event bus |
|
||||
| `Orbit.config()` | the resolved runtime config |
|
||||
| `Orbit.React` / `Orbit.h` / `Orbit.html` | render primitives |
|
||||
| `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime` / `Orbit.Fragment` / `Orbit.h` / `Orbit.html` | render primitives (externalization targets for compiled plugins) |
|
||||
|
||||
### Inside `Orbit.plugin(name, (orbit) => …)`
|
||||
| Member | Description |
|
||||
|
|
@ -67,6 +67,7 @@ bound to the app's React) — runtime template markup, no build step. Prefer
|
|||
| `orbit.themes.current()/list()/set(id)` | read/set the theme |
|
||||
| `orbit.storage.get(key, def)/set(key, val)` | namespaced persistence |
|
||||
| `orbit.addUi(slot, render)` | add UI to a slot (returns a remover) |
|
||||
| `orbit.addSettingsSection({label, icon?, render})` | add a whole Settings section |
|
||||
| `orbit.h / orbit.html` | render helpers |
|
||||
| `log(…)` | namespaced console logger |
|
||||
|
||||
|
|
@ -79,9 +80,34 @@ bound to the app's React) — runtime template markup, no build step. Prefer
|
|||
| Slot | Where |
|
||||
|---|---|
|
||||
| `composer_button` | a button in the message composer toolbar |
|
||||
| `settings_section` | a whole section in Settings (own nav entry + pane) — use `orbit.addSettingsSection()` |
|
||||
|
||||
More slots (settings panel, message decorators, side panels) will be added as
|
||||
the core grows stable homes for them.
|
||||
More slots (message decorators, side panels) will be added as the core grows
|
||||
stable homes for them.
|
||||
|
||||
## Compiled plugins (write real React)
|
||||
|
||||
The example above is an *uncompiled* `.js` plugin. For anything substantial,
|
||||
build a plugin like a normal project and compile it to one droppable file — the
|
||||
same model as KiwiIRC's webpack plugins.
|
||||
|
||||
The trick: mark `react`, `react-dom` and `react/jsx-runtime` **external** and map
|
||||
them to `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime`, so your bundle
|
||||
shares Orbit's single React instance and never carries its own. (Bundling your
|
||||
own React breaks hooks with "invalid hook call".) Then author normal TSX with
|
||||
hooks/state and render it into a slot:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
Orbit.plugin('my-plugin', (orbit) => {
|
||||
orbit.addSettingsSection({ label: 'My plugin', icon: '🧩', render: () => <Panel orbit={orbit} /> });
|
||||
});
|
||||
```
|
||||
|
||||
A ready-to-copy starter (Vite config with the externals already set up, tsconfig,
|
||||
ambient types and an example) lives in [`plugin-template/`](../plugin-template).
|
||||
`npm install && npm run build` → one `dist/*.js` you drop in and list in
|
||||
`config.json`.
|
||||
|
||||
## Intentionally not exposed
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'plugin-template']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
|
|
|||
2
plugin-template/.gitignore
vendored
Normal file
2
plugin-template/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
37
plugin-template/README.md
Normal file
37
plugin-template/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Orbit plugin template
|
||||
|
||||
A starter for a **compiled** Orbit plugin — write real React/TSX with hooks and
|
||||
build it down to a single droppable `.js` file (the same model as KiwiIRC's
|
||||
webpack plugins, in React/Vite).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # → dist/orbit-plugin-template.js
|
||||
```
|
||||
|
||||
`react`, `react-dom` and `react/jsx-runtime` are marked **external** in
|
||||
`vite.config.ts` and mapped to `Orbit.React` / `Orbit.ReactDOM` /
|
||||
`Orbit.jsxRuntime`. So your bundle does **not** contain its own React — at
|
||||
runtime it shares Orbit's single instance. (Bundling your own React would break
|
||||
hooks with "invalid hook call".)
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Copy `dist/orbit-plugin-template.js` to where Orbit can fetch it, e.g.
|
||||
`/app/plugins/orbit-plugin-template.js`.
|
||||
2. List it in the deployed `config.json` (no rebuild of Orbit needed):
|
||||
|
||||
```json
|
||||
{ "plugins": ["/app/plugins/orbit-plugin-template.js"] }
|
||||
```
|
||||
|
||||
3. Reload. The example adds a 🤷 composer button and a **Template** section in
|
||||
Settings.
|
||||
|
||||
## Write your plugin
|
||||
|
||||
Edit `src/index.tsx`. The `Orbit` global and the per-plugin `orbit` API are typed
|
||||
in `src/orbit.d.ts`. See Orbit's [`docs/PLUGINS.md`](../docs/PLUGINS.md) for the
|
||||
full API and event list.
|
||||
20
plugin-template/package.json
Normal file
20
plugin-template/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "orbit-plugin-template",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Starter for a compiled Orbit plugin (React/TSX → one droppable JS file).",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"watch": "vite build --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
52
plugin-template/src/index.tsx
Normal file
52
plugin-template/src/index.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useState } from 'react';
|
||||
import type { OrbitPluginApi } from './orbit';
|
||||
|
||||
// A COMPILED Orbit plugin: real React/TSX with hooks, built to one droppable file
|
||||
// (npm run build → dist/orbit-plugin-template.js). React is shared with the host,
|
||||
// so hooks/state/context all work. Register against the Orbit global:
|
||||
Orbit.plugin('orbit-template', (orbit, log) => {
|
||||
log('template plugin loaded — Orbit v' + orbit.version);
|
||||
|
||||
// 1) A button in the composer (a real component).
|
||||
orbit.addUi('composer_button', () => <ShrugButton orbit={orbit} />);
|
||||
|
||||
// 2) A whole Settings section (own nav entry + pane), with stateful UI.
|
||||
orbit.addSettingsSection({
|
||||
label: 'Template',
|
||||
icon: '🧩',
|
||||
render: () => <TemplatePanel orbit={orbit} />,
|
||||
});
|
||||
});
|
||||
|
||||
function ShrugButton({ orbit }: { orbit: OrbitPluginApi }) {
|
||||
return (
|
||||
<button
|
||||
className="composer__emoji"
|
||||
title="Shrug (template plugin)"
|
||||
onClick={() => orbit.irc.say('¯\\_(ツ)_/¯')}
|
||||
>🤷</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePanel({ orbit }: { orbit: OrbitPluginApi }) {
|
||||
// Hooks work because the plugin shares Orbit's React instance.
|
||||
const [count, setCount] = useState<number>(() => orbit.storage.get('count', 0) ?? 0);
|
||||
const bump = () => { const n = count + 1; setCount(n); orbit.storage.set('count', n); };
|
||||
|
||||
return (
|
||||
<div className="scard">
|
||||
<div className="scard__body">
|
||||
<div className="sfield">
|
||||
<div className="sfield__intro">
|
||||
This panel is a <b>compiled</b> plugin — real React with hooks, built to a
|
||||
single file and loaded via <code>config.json</code>. You're chatting as{' '}
|
||||
<b>{orbit.state.nick()}</b>.
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal__actions">
|
||||
<button className="upbtn upbtn--primary" onClick={bump}>Clicked {count}×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
plugin-template/src/orbit.d.ts
vendored
Normal file
47
plugin-template/src/orbit.d.ts
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Minimal ambient types for the Orbit plugin global. Hand-written for plugin
|
||||
// authors; the host's full types live in Orbit's src/plugins/api.ts.
|
||||
import type * as React from 'react';
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
export interface OrbitPluginApi {
|
||||
name: string;
|
||||
version: string;
|
||||
commit: string;
|
||||
React: typeof React;
|
||||
on(event: string, fn: Handler): () => void;
|
||||
once(event: string, fn: Handler): () => void;
|
||||
off(event: string, fn: Handler): void;
|
||||
emit(event: string, ...args: unknown[]): void;
|
||||
state: {
|
||||
active(): string;
|
||||
nick(): string;
|
||||
account(): string;
|
||||
buffers(): string[];
|
||||
get(): unknown;
|
||||
};
|
||||
irc: {
|
||||
send(line: string): void;
|
||||
msg(target: string, text: string): void;
|
||||
say(text: string): void;
|
||||
join(channel: string): void;
|
||||
part(channel: string): void;
|
||||
list(): void;
|
||||
};
|
||||
themes: { current(): string; list(): string[]; set(theme: string): void };
|
||||
storage: { get<T>(key: string, fallback?: T): T | undefined; set(key: string, value: unknown): void };
|
||||
addUi(slot: 'composer_button', render: () => React.ReactNode): () => void;
|
||||
addSettingsSection(opts: { label: string; icon?: string; render: () => React.ReactNode }): () => void;
|
||||
log(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
const Orbit: {
|
||||
version: string;
|
||||
commit: string;
|
||||
React: typeof React;
|
||||
plugin(name: string, fn: (orbit: OrbitPluginApi, log: Handler) => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
14
plugin-template/tsconfig.json
Normal file
14
plugin-template/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
28
plugin-template/vite.config.ts
Normal file
28
plugin-template/vite.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Builds the plugin to ONE droppable IIFE file. React, react-dom and the JSX
|
||||
// runtime are EXTERNAL — at runtime they resolve to Orbit's single React instance
|
||||
// (window.Orbit.*), never bundled. Bundling your own React would break hooks.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/index.tsx',
|
||||
formats: ['iife'],
|
||||
name: 'OrbitPluginTemplate',
|
||||
fileName: () => 'orbit-plugin-template.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'Orbit.React',
|
||||
'react-dom': 'Orbit.ReactDOM',
|
||||
'react/jsx-runtime': 'Orbit.jsxRuntime',
|
||||
},
|
||||
},
|
||||
},
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Tchatou service worker — installable PWA + offline app shell.
|
||||
// Scope: /app/. Only handles same-origin /app/ GETs; the IRC websocket and all
|
||||
// API calls (cloudflare, change_password, upload) pass straight through.
|
||||
const CACHE = 'tchatou-v38';
|
||||
const CACHE = 'tchatou-v39';
|
||||
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ import { getConfig } from '../../config';
|
|||
import { getTheme, setTheme, type Theme } from '../../ui/theme';
|
||||
import { isPushSupported, pushEnabledPref, enablePush, disablePush } from '../../services/push';
|
||||
import { CAP_INFO } from '../../irc/cap-info';
|
||||
import { usePluginRegistry } from '../../plugins/registry';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { Turnstile } from '../Turnstile';
|
||||
|
||||
// Renders one plugin-contributed settings section, isolating render errors.
|
||||
function PluginSettingsSlot({ render }: { render: () => ReactNode }) {
|
||||
try { return <>{render()}</>; } catch (e) { console.error('[plugins] settings_section render error', e); return null; }
|
||||
}
|
||||
|
||||
// Labels & descriptions are resolved via i18n (SEC_KEY / SEC_DESC).
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: 'profil', icon: '👤' },
|
||||
|
|
@ -67,7 +73,10 @@ export function SettingsModal() {
|
|||
const { t } = useTranslation();
|
||||
const setModal = useChat((s) => s.setModal);
|
||||
const account = useChat((s) => s.account);
|
||||
const [section, setSection] = useState<SettingsSection>('profil');
|
||||
// Stable-ref selector (zustand v5 loops on a new array each render); filter in body.
|
||||
const pluginUi = usePluginRegistry((s) => s.ui);
|
||||
const pluginSections = pluginUi.filter((u) => u.slot === 'settings_section');
|
||||
const [section, setSection] = useState<string>('profil');
|
||||
const [drilled, setDrilled] = useState(false); // mobile: are we inside a section?
|
||||
const close = () => setModal('');
|
||||
|
||||
|
|
@ -78,7 +87,8 @@ export function SettingsModal() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const cur = SETTINGS_SECTIONS.find((s) => s.id === section)!;
|
||||
const curFixed = SETTINGS_SECTIONS.find((s) => s.id === section);
|
||||
const curPlugin = pluginSections.find((p) => p.id === section);
|
||||
|
||||
return (
|
||||
<div className="settings-backdrop" onClick={close}>
|
||||
|
|
@ -101,6 +111,17 @@ export function SettingsModal() {
|
|||
<span className="settings__navchev" aria-hidden>›</span>
|
||||
</button>
|
||||
))}
|
||||
{pluginSections.map((ps) => (
|
||||
<button key={ps.id} className={`settings__navitem ${section === ps.id ? 'is-on' : ''}`}
|
||||
onClick={() => { setSection(ps.id); setDrilled(true); }}>
|
||||
<span className="settings__navic" aria-hidden>{ps.meta?.icon ?? '🧩'}</span>
|
||||
<span className="settings__navtxt">
|
||||
<span className="settings__navlabel">{ps.meta?.label ?? ps.plugin}</span>
|
||||
<span className="settings__navdesc">{ps.plugin}</span>
|
||||
</span>
|
||||
<span className="settings__navchev" aria-hidden>›</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<a className="settings__about" href={getConfig().branding.projectUrl} target="_blank" rel="noopener noreferrer">
|
||||
<span className="settings__about-mark" aria-hidden>◐</span>
|
||||
|
|
@ -115,8 +136,8 @@ export function SettingsModal() {
|
|||
<section className="settings__pane">
|
||||
<header className="settings__top">
|
||||
<button className="settings__back" onClick={() => setDrilled(false)} aria-label={t('settings.misc.back')}>‹</button>
|
||||
<span className="settings__top-ic" aria-hidden><SecIcon id={cur.id} icon={cur.icon} /></span>
|
||||
<h3 className="settings__top-title">{t(SEC_KEY[cur.id])}</h3>
|
||||
<span className="settings__top-ic" aria-hidden>{curFixed ? <SecIcon id={curFixed.id} icon={curFixed.icon} /> : (curPlugin?.meta?.icon ?? '🧩')}</span>
|
||||
<h3 className="settings__top-title">{curFixed ? t(SEC_KEY[curFixed.id]) : (curPlugin?.meta?.label ?? '')}</h3>
|
||||
<button className="settings__close settings__close--pane" onClick={close} aria-label={t('modals.closeButton')}>✕</button>
|
||||
</header>
|
||||
<div className="settings__content" key={section}>
|
||||
|
|
@ -127,6 +148,7 @@ export function SettingsModal() {
|
|||
{section === 'server' && <ServerSection />}
|
||||
{section === 'ircv3' && <CapabilitiesSection />}
|
||||
{section === 'about' && <AboutSection />}
|
||||
{curPlugin && <PluginSettingsSlot render={curPlugin.render} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
// change. UI authoring uses HTM (htm.bind to the app's React) so plugins write
|
||||
// template-literal markup at runtime, with no build step.
|
||||
import React, { type ReactNode } from 'react';
|
||||
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import htm from 'htm';
|
||||
import { useChat } from '../store';
|
||||
import { getTheme, setTheme, type Theme } from '../ui/theme';
|
||||
|
|
@ -24,8 +26,13 @@ export interface OrbitPluginApi {
|
|||
/** App build version + git commit. */
|
||||
version: string;
|
||||
commit: string;
|
||||
/** The app's React instance + render helpers (single instance — nodes interop). */
|
||||
/** The app's React instance + render helpers (single instance — nodes interop).
|
||||
* Compiled plugins externalize `react`/`react-dom`/`react/jsx-runtime` to these
|
||||
* so they share Orbit's React (never bundle their own — see docs/PLUGINS.md). */
|
||||
React: typeof React;
|
||||
ReactDOM: typeof ReactDOM;
|
||||
jsxRuntime: typeof ReactJSXRuntime;
|
||||
Fragment: typeof React.Fragment;
|
||||
h: typeof React.createElement;
|
||||
/** Tagged-template markup, e.g. html`<button onClick=${fn}>Hi</button>`. */
|
||||
html: typeof html;
|
||||
|
|
@ -58,6 +65,8 @@ export interface OrbitPluginApi {
|
|||
storage: { get: <T>(key: string, fallback?: T) => T | undefined; set: (key: string, value: unknown) => void };
|
||||
// ── UI extension (named slots — see registry.ts) ─────────────────────────
|
||||
addUi: (slot: UiSlot, render: () => ReactNode) => () => void;
|
||||
/** Add a whole section to Settings (own nav entry + pane). */
|
||||
addSettingsSection: (opts: { label: string; icon?: string; render: () => ReactNode }) => () => void;
|
||||
}
|
||||
|
||||
function makeApi(name: string): OrbitPluginApi {
|
||||
|
|
@ -66,7 +75,7 @@ function makeApi(name: string): OrbitPluginApi {
|
|||
name,
|
||||
version: __APP_VERSION__,
|
||||
commit: __GIT_COMMIT__,
|
||||
React, h: React.createElement, html,
|
||||
React, ReactDOM, jsxRuntime: ReactJSXRuntime, Fragment: React.Fragment, h: React.createElement, html,
|
||||
log: (...a) => console.log(`%c[plugin:${name}]`, 'color:#2ea043', ...a),
|
||||
on: bus.on, once: bus.once, off: bus.off, emit: bus.emit,
|
||||
state: {
|
||||
|
|
@ -93,6 +102,8 @@ function makeApi(name: string): OrbitPluginApi {
|
|||
set: (key, value) => { try { localStorage.setItem(ns + key, JSON.stringify(value)); } catch { /* quota */ } },
|
||||
},
|
||||
addUi: (slot, render) => usePluginRegistry.getState().addUi(slot, name, render),
|
||||
addSettingsSection: (opts) =>
|
||||
usePluginRegistry.getState().addUi('settings_section', name, opts.render, { label: opts.label, icon: opts.icon }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +111,9 @@ function makeApi(name: string): OrbitPluginApi {
|
|||
export const Orbit = {
|
||||
version: __APP_VERSION__,
|
||||
commit: __GIT_COMMIT__,
|
||||
React, h: React.createElement, html,
|
||||
// Render primitives — compiled plugins externalize react/react-dom/jsx-runtime
|
||||
// to these (one shared React instance). See plugin-template/ + docs/PLUGINS.md.
|
||||
React, ReactDOM, jsxRuntime: ReactJSXRuntime, Fragment: React.Fragment, h: React.createElement, html,
|
||||
on: bus.on, once: bus.once, off: bus.off, emit: bus.emit,
|
||||
config: () => getConfig(),
|
||||
plugin(name: string, fn: (orbit: OrbitPluginApi, log: OrbitPluginApi['log']) => void): void {
|
||||
|
|
|
|||
|
|
@ -5,25 +5,27 @@ import { create } from 'zustand';
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
// The UI slots the core currently exposes. Add more as components grow homes.
|
||||
export type UiSlot = 'composer_button';
|
||||
export type UiSlot = 'composer_button' | 'settings_section';
|
||||
|
||||
export interface PluginUi {
|
||||
id: string;
|
||||
plugin: string;
|
||||
slot: UiSlot;
|
||||
render: () => ReactNode;
|
||||
// Nav metadata for slots that need a label/icon (e.g. settings_section).
|
||||
meta?: { label?: string; icon?: string };
|
||||
}
|
||||
|
||||
interface RegistryState {
|
||||
ui: PluginUi[];
|
||||
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode) => () => void;
|
||||
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode, meta?: PluginUi['meta']) => () => void;
|
||||
}
|
||||
|
||||
export const usePluginRegistry = create<RegistryState>((set) => ({
|
||||
ui: [],
|
||||
addUi: (slot, plugin, render) => {
|
||||
addUi: (slot, plugin, render, meta) => {
|
||||
const id = `${plugin}:${slot}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
set((s) => ({ ui: [...s.ui, { id, plugin, slot, render }] }));
|
||||
set((s) => ({ ui: [...s.ui, { id, plugin, slot, render, meta }] }));
|
||||
return () => set((s) => ({ ui: s.ui.filter((u) => u.id !== id) }));
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue