legend-state-dev-tools 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/examples/basic/index.html +12 -0
- package/examples/basic/package.json +24 -0
- package/examples/basic/src/App.tsx +50 -0
- package/examples/basic/src/main.tsx +16 -0
- package/examples/basic/src/state.ts +17 -0
- package/examples/basic/tsconfig.json +15 -0
- package/examples/basic/vite.config.ts +6 -0
- package/package.json +15 -0
- package/packages/core/package.json +31 -0
- package/packages/core/src/eta.d.ts +4 -0
- package/packages/core/src/index.ts +130 -0
- package/packages/core/src/state-bridge.ts +56 -0
- package/packages/core/src/styles.css +184 -0
- package/packages/core/src/ui/json-editor-mount.tsx +144 -0
- package/packages/core/src/ui/panel.ts +96 -0
- package/packages/core/src/ui/shared-utils.ts +49 -0
- package/packages/core/src/ui/template-engine.ts +55 -0
- package/packages/core/src/ui/templates/panel.eta +12 -0
- package/packages/core/src/ui/templates/toolbar.eta +13 -0
- package/packages/core/src/ui/toolbar.ts +108 -0
- package/packages/core/tsconfig.json +9 -0
- package/packages/core/vite.config.ts +71 -0
- package/tsconfig.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# legend-state-dev-tools
|
|
2
|
+
|
|
3
|
+
> **Early-stage project** -- I discovered Legend State recently and chose it for a project I was working on. It had everything I needed from a state manager, but I couldn't live without dev tools, so I built this. It appears to work, but it hasn't been thoroughly tested yet -- consider it a proof of concept for now.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/legend-state-dev-tools)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
Visual dev tools for Legend State v3 -- inspect and edit observable state in real time.
|
|
9
|
+
|
|
10
|
+
<!-- screenshot -->
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- Real-time state tree view powered by `json-edit-react`
|
|
15
|
+
- Inline editing of observable values
|
|
16
|
+
- Multiple color themes (dark and light variants)
|
|
17
|
+
- Draggable toolbar
|
|
18
|
+
- Configurable panel positioning (left or right)
|
|
19
|
+
- Read-only mode
|
|
20
|
+
- Clean teardown via `destroy()`
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install legend-state-dev-tools
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add legend-state-dev-tools
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
yarn add legend-state-dev-tools
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Peer dependencies
|
|
37
|
+
|
|
38
|
+
| Package | Version |
|
|
39
|
+
|---------|---------|
|
|
40
|
+
| `@legendapp/state` | `>= 3.0.0-beta.0` |
|
|
41
|
+
| `react` | `>= 18.0.0` |
|
|
42
|
+
| `react-dom` | `>= 18.0.0` |
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { observable } from '@legendapp/state';
|
|
48
|
+
import { init } from 'legend-state-dev-tools';
|
|
49
|
+
import 'legend-state-dev-tools/dist/style.css';
|
|
50
|
+
|
|
51
|
+
const state$ = observable({ count: 0, user: { name: 'Alice' } });
|
|
52
|
+
|
|
53
|
+
const devtools = init(state$);
|
|
54
|
+
|
|
55
|
+
// Later, to clean up:
|
|
56
|
+
// devtools.destroy();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API Reference
|
|
60
|
+
|
|
61
|
+
### `init(observable$, options?)`
|
|
62
|
+
|
|
63
|
+
Mounts the dev tools UI and returns a handle for cleanup.
|
|
64
|
+
|
|
65
|
+
**Parameters**
|
|
66
|
+
|
|
67
|
+
| Parameter | Type | Description |
|
|
68
|
+
|-----------|------|-------------|
|
|
69
|
+
| `observable$` | `ObservableParam<any>` | The Legend State observable to inspect |
|
|
70
|
+
| `options` | `DevToolsOptions` | Optional configuration (see below) |
|
|
71
|
+
|
|
72
|
+
**Options**
|
|
73
|
+
|
|
74
|
+
| Option | Type | Default | Description |
|
|
75
|
+
|--------|------|---------|-------------|
|
|
76
|
+
| `enabled` | `boolean` | `true` | Enable or disable the dev tools |
|
|
77
|
+
| `readOnly` | `boolean` | `false` | Prevent editing of state values |
|
|
78
|
+
| `theme` | `string` | `'githubDark'` | Color theme for the JSON editor |
|
|
79
|
+
| `rootName` | `string` | `'state$'` | Label shown as the root node name |
|
|
80
|
+
| `position` | `'left' \| 'right'` | `'right'` | Side of the screen for the panel |
|
|
81
|
+
|
|
82
|
+
**Returns**
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
{ destroy: () => void }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Call `destroy()` to unmount the toolbar, panel, and state subscription.
|
|
89
|
+
|
|
90
|
+
## Themes
|
|
91
|
+
|
|
92
|
+
The following themes are available (provided by `json-edit-react`):
|
|
93
|
+
|
|
94
|
+
| Theme key | Description |
|
|
95
|
+
|-----------|-------------|
|
|
96
|
+
| `githubDark` | GitHub dark (default) |
|
|
97
|
+
| `githubLight` | GitHub light |
|
|
98
|
+
| `monoDark` | Monochrome dark |
|
|
99
|
+
| `monoLight` | Monochrome light |
|
|
100
|
+
|
|
101
|
+
## Example
|
|
102
|
+
|
|
103
|
+
A working example is included in `examples/basic/`. To run it:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install
|
|
107
|
+
npm run dev
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This builds the core package and starts the Vite dev server for the example app.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git clone <repo-url>
|
|
116
|
+
cd legend-state-dev-tools
|
|
117
|
+
npm install
|
|
118
|
+
npm run build # build the core package
|
|
119
|
+
npm run dev # build + start example dev server
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Architecture
|
|
123
|
+
|
|
124
|
+
The project is a monorepo with the main package in `packages/core/` and examples in `examples/`.
|
|
125
|
+
|
|
126
|
+
| Module | Path | Role |
|
|
127
|
+
|--------|------|------|
|
|
128
|
+
| `index` | `packages/core/src/index.ts` | Public API (`init`, options, lifecycle) |
|
|
129
|
+
| `state-bridge` | `packages/core/src/state-bridge.ts` | Subscribes to observables, produces snapshots, writes back edits |
|
|
130
|
+
| `json-editor-mount` | `packages/core/src/ui/json-editor-mount.tsx` | Mounts the `json-edit-react` editor with theme resolution |
|
|
131
|
+
| `panel` | `packages/core/src/ui/panel.ts` | Slide-out panel DOM management |
|
|
132
|
+
| `toolbar` | `packages/core/src/ui/toolbar.ts` | Draggable floating toolbar |
|
|
133
|
+
| `template-engine` | `packages/core/src/ui/template-engine.ts` | Lightweight HTML templating (Eta) |
|
|
134
|
+
| `styles` | `packages/core/src/styles.css` | Panel and toolbar CSS |
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Legend State Dev Tools - Example</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "legend-state-dev-tools-example",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@legendapp/state": "^3.0.0-beta.43",
|
|
12
|
+
"react": "^18.3.0",
|
|
13
|
+
"react-dom": "^18.3.0",
|
|
14
|
+
"json-edit-react": "^1.16.0",
|
|
15
|
+
"legend-state-dev-tools": "*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.2.0",
|
|
19
|
+
"@types/react-dom": "^18.2.0",
|
|
20
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
21
|
+
"typescript": "^5.3.0",
|
|
22
|
+
"vite": "^6.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { observer } from '@legendapp/state/react';
|
|
3
|
+
import { state$ } from './state';
|
|
4
|
+
|
|
5
|
+
export const App = observer(function App() {
|
|
6
|
+
// In Legend State v3, use .get() to subscribe to values
|
|
7
|
+
const count = (state$.count as any).get();
|
|
8
|
+
const userName = (state$.user.name as any).get();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ padding: 40, fontFamily: 'sans-serif', maxWidth: 600 }}>
|
|
12
|
+
<h1>Legend State Dev Tools Demo</h1>
|
|
13
|
+
<p>Open the dev tools panel using the floating button at the bottom-right.</p>
|
|
14
|
+
|
|
15
|
+
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8, color: '#1a1a1a' }}>
|
|
16
|
+
<h2>Counter: {count}</h2>
|
|
17
|
+
<button onClick={() => (state$.count as any).set((c: number) => c + 1)}>
|
|
18
|
+
Increment
|
|
19
|
+
</button>
|
|
20
|
+
<button onClick={() => (state$.count as any).set(0)} style={{ marginLeft: 8 }}>
|
|
21
|
+
Reset
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8, color: '#1a1a1a' }}>
|
|
26
|
+
<h2>User: {userName}</h2>
|
|
27
|
+
<input
|
|
28
|
+
value={userName}
|
|
29
|
+
onChange={(e) => (state$.user.name as any).set(e.target.value)}
|
|
30
|
+
style={{ padding: '4px 8px', fontSize: 14 }}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8, color: '#1a1a1a' }}>
|
|
35
|
+
<h2>Todos</h2>
|
|
36
|
+
<button
|
|
37
|
+
onClick={() =>
|
|
38
|
+
(state$.todos as any).push({
|
|
39
|
+
id: Date.now(),
|
|
40
|
+
text: `New todo ${Date.now()}`,
|
|
41
|
+
done: false,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
Add Todo
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { init } from 'legend-state-dev-tools';
|
|
4
|
+
import 'legend-state-dev-tools/dist/styles.css';
|
|
5
|
+
import { state$ } from './state';
|
|
6
|
+
import { App } from './App';
|
|
7
|
+
|
|
8
|
+
// Initialize dev tools
|
|
9
|
+
init(state$, {
|
|
10
|
+
rootName: 'state$',
|
|
11
|
+
theme: 'githubDark',
|
|
12
|
+
readOnly: false,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const root = createRoot(document.getElementById('root')!);
|
|
16
|
+
root.render(<App />);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { observable } from '@legendapp/state';
|
|
2
|
+
|
|
3
|
+
export const state$ = observable({
|
|
4
|
+
count: 0,
|
|
5
|
+
user: {
|
|
6
|
+
name: 'Alice',
|
|
7
|
+
email: 'alice@example.com',
|
|
8
|
+
preferences: {
|
|
9
|
+
darkMode: true,
|
|
10
|
+
notifications: true,
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
todos: [
|
|
14
|
+
{ id: 1, text: 'Learn Legend State', done: true },
|
|
15
|
+
{ id: 2, text: 'Try dev tools', done: false },
|
|
16
|
+
],
|
|
17
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"jsx": "react-jsx",
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"isolatedModules": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "legend-state-dev-tools",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"workspaces": [
|
|
6
|
+
"packages/*",
|
|
7
|
+
"examples/*"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "npm run build -w packages/core",
|
|
11
|
+
"dev": "npm run build && npm run dev -w examples/basic",
|
|
12
|
+
"clean": "rm -rf node_modules packages/*/dist packages/*/node_modules examples/*/node_modules"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT"
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "legend-state-dev-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dev tools for Legend State v3 - view and edit observable state",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "vite build",
|
|
13
|
+
"dev": "vite build --watch"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^18.2.0",
|
|
17
|
+
"@types/react-dom": "^18.2.0",
|
|
18
|
+
"typescript": "^5.3.0",
|
|
19
|
+
"vite": "^6.0.0",
|
|
20
|
+
"vite-plugin-dts": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@legendapp/state": ">=3.0.0-beta.0",
|
|
24
|
+
"react": ">=18.0.0",
|
|
25
|
+
"react-dom": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"eta": "^3.5.0",
|
|
29
|
+
"json-edit-react": "^1.16.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ObservableParam } from '@legendapp/state';
|
|
2
|
+
import { Toolbar } from './ui/toolbar';
|
|
3
|
+
import { Panel } from './ui/panel';
|
|
4
|
+
import { createStateBridge, type StateBridge } from './state-bridge';
|
|
5
|
+
import { mountJsonEditor, type JsonEditorBridge } from './ui/json-editor-mount';
|
|
6
|
+
import { createCleanup } from './ui/shared-utils';
|
|
7
|
+
|
|
8
|
+
export interface DevToolsOptions {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
readOnly?: boolean;
|
|
11
|
+
theme?: string;
|
|
12
|
+
rootName?: string;
|
|
13
|
+
position?: 'left' | 'right';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DevTools {
|
|
17
|
+
destroy: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function init(
|
|
21
|
+
observable$: ObservableParam<any>,
|
|
22
|
+
options: DevToolsOptions = {}
|
|
23
|
+
): DevTools {
|
|
24
|
+
const {
|
|
25
|
+
enabled = true,
|
|
26
|
+
readOnly = false,
|
|
27
|
+
theme = 'githubDark',
|
|
28
|
+
rootName = 'state$',
|
|
29
|
+
position = 'right',
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
if (!enabled) {
|
|
33
|
+
return { destroy: () => {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cleanup = createCleanup();
|
|
37
|
+
let panel: Panel | null = null;
|
|
38
|
+
let toolbar: Toolbar | null = null;
|
|
39
|
+
let bridge: StateBridge | null = null;
|
|
40
|
+
let editorBridge: JsonEditorBridge | null = null;
|
|
41
|
+
|
|
42
|
+
// Create panel
|
|
43
|
+
panel = new Panel({
|
|
44
|
+
rootName,
|
|
45
|
+
readOnly,
|
|
46
|
+
position,
|
|
47
|
+
onClose: () => {
|
|
48
|
+
hidePanel();
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const showPanel = () => {
|
|
53
|
+
if (!panel) return;
|
|
54
|
+
panel.show();
|
|
55
|
+
toolbar?.setPanelVisible(true);
|
|
56
|
+
|
|
57
|
+
// Poll for editor root element (innerHTML may not be ready immediately)
|
|
58
|
+
const tryMount = (retries = 10) => {
|
|
59
|
+
const editorRoot = panel?.getEditorRoot();
|
|
60
|
+
if (!editorRoot) {
|
|
61
|
+
if (retries > 0) {
|
|
62
|
+
setTimeout(() => tryMount(retries - 1), 16);
|
|
63
|
+
} else {
|
|
64
|
+
console.warn('[Legend State DevTools] Could not find #lsdt-json-editor-root after retries');
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (editorBridge) return;
|
|
69
|
+
|
|
70
|
+
const initialData = bridge?.getSnapshot() ?? {};
|
|
71
|
+
|
|
72
|
+
editorBridge = mountJsonEditor(editorRoot, {
|
|
73
|
+
initialData,
|
|
74
|
+
onEdit: (newData: unknown) => {
|
|
75
|
+
bridge?.setData(newData);
|
|
76
|
+
},
|
|
77
|
+
readOnly,
|
|
78
|
+
theme,
|
|
79
|
+
rootName,
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
tryMount();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const hidePanel = () => {
|
|
86
|
+
if (editorBridge) {
|
|
87
|
+
editorBridge.destroy();
|
|
88
|
+
editorBridge = null;
|
|
89
|
+
}
|
|
90
|
+
panel?.hide();
|
|
91
|
+
toolbar?.setPanelVisible(false);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const togglePanel = () => {
|
|
95
|
+
if (panel?.isVisible()) {
|
|
96
|
+
hidePanel();
|
|
97
|
+
} else {
|
|
98
|
+
showPanel();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Create toolbar
|
|
103
|
+
toolbar = new Toolbar({
|
|
104
|
+
onTogglePanel: togglePanel,
|
|
105
|
+
rootName,
|
|
106
|
+
});
|
|
107
|
+
toolbar.mount();
|
|
108
|
+
cleanup.add(() => toolbar?.unmount());
|
|
109
|
+
|
|
110
|
+
// Create state bridge
|
|
111
|
+
bridge = createStateBridge(observable$, {
|
|
112
|
+
onSnapshot: (snapshot) => {
|
|
113
|
+
editorBridge?.updateData(snapshot);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
cleanup.add(() => bridge?.destroy());
|
|
117
|
+
cleanup.add(() => {
|
|
118
|
+
if (editorBridge) {
|
|
119
|
+
editorBridge.destroy();
|
|
120
|
+
editorBridge = null;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
cleanup.add(() => panel?.unmount());
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
destroy: () => {
|
|
127
|
+
cleanup.run();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ObservableParam } from '@legendapp/state';
|
|
2
|
+
|
|
3
|
+
export interface StateBridgeOptions {
|
|
4
|
+
onSnapshot: (snapshot: unknown) => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface StateBridge {
|
|
8
|
+
getSnapshot: () => unknown;
|
|
9
|
+
setData: (newData: unknown) => void;
|
|
10
|
+
destroy: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createStateBridge(
|
|
14
|
+
observable$: ObservableParam<any>,
|
|
15
|
+
options: StateBridgeOptions
|
|
16
|
+
): StateBridge {
|
|
17
|
+
// Get initial snapshot
|
|
18
|
+
const getSnapshot = () => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(JSON.stringify((observable$ as any).peek()));
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Subscribe to changes using onChange
|
|
27
|
+
let dispose: (() => void) | null = null;
|
|
28
|
+
try {
|
|
29
|
+
dispose = (observable$ as any).onChange(
|
|
30
|
+
() => {
|
|
31
|
+
const snapshot = getSnapshot();
|
|
32
|
+
options.onSnapshot(snapshot);
|
|
33
|
+
},
|
|
34
|
+
{ trackingType: false }
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
console.warn('[Legend State DevTools] Could not subscribe to observable changes via onChange');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
getSnapshot,
|
|
42
|
+
setData: (newData: unknown) => {
|
|
43
|
+
try {
|
|
44
|
+
(observable$ as any).set(newData);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error('[Legend State DevTools] Failed to set data:', e);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
destroy: () => {
|
|
50
|
+
if (dispose) {
|
|
51
|
+
dispose();
|
|
52
|
+
dispose = null;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* Legend State Dev Tools Styles */
|
|
2
|
+
|
|
3
|
+
/* Toolbar */
|
|
4
|
+
#lsdt-toolbar {
|
|
5
|
+
position: fixed;
|
|
6
|
+
bottom: 20px;
|
|
7
|
+
right: 20px;
|
|
8
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
9
|
+
color: white;
|
|
10
|
+
padding: 10px 14px;
|
|
11
|
+
border-radius: 12px;
|
|
12
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
z-index: 999999;
|
|
16
|
+
cursor: move;
|
|
17
|
+
user-select: none;
|
|
18
|
+
backdrop-filter: blur(10px);
|
|
19
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
20
|
+
transition: all 0.2s ease;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#lsdt-toolbar.dragging {
|
|
24
|
+
opacity: 0.8;
|
|
25
|
+
cursor: grabbing;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.lsdt-toolbar-header {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: space-between;
|
|
32
|
+
gap: 10px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.lsdt-toolbar-title {
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
font-size: 13px;
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.lsdt-toolbar-indicator {
|
|
45
|
+
width: 8px;
|
|
46
|
+
height: 8px;
|
|
47
|
+
background: #4ade80;
|
|
48
|
+
border-radius: 50%;
|
|
49
|
+
box-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
|
|
50
|
+
flex-shrink: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.lsdt-toggle-btn {
|
|
54
|
+
background: rgba(255, 255, 255, 0.2);
|
|
55
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
56
|
+
color: white;
|
|
57
|
+
padding: 5px 10px;
|
|
58
|
+
border-radius: 6px;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
font-weight: 500;
|
|
62
|
+
transition: all 0.2s;
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.lsdt-toggle-btn:hover {
|
|
67
|
+
background: rgba(255, 255, 255, 0.3);
|
|
68
|
+
transform: translateY(-1px);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.lsdt-toggle-btn.active {
|
|
72
|
+
background: rgba(255, 255, 255, 0.4);
|
|
73
|
+
border-color: rgba(255, 255, 255, 0.5);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Panel */
|
|
77
|
+
#lsdt-panel {
|
|
78
|
+
position: fixed;
|
|
79
|
+
top: 20px;
|
|
80
|
+
right: 20px;
|
|
81
|
+
width: 420px;
|
|
82
|
+
max-height: calc(100vh - 100px);
|
|
83
|
+
background: #1e1e2e;
|
|
84
|
+
border-radius: 12px;
|
|
85
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
86
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
87
|
+
z-index: 999999;
|
|
88
|
+
overflow: hidden;
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#lsdt-panel.lsdt-panel-left {
|
|
95
|
+
right: auto;
|
|
96
|
+
left: 20px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.lsdt-panel-header {
|
|
100
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
101
|
+
color: white;
|
|
102
|
+
padding: 14px 16px;
|
|
103
|
+
display: flex;
|
|
104
|
+
justify-content: space-between;
|
|
105
|
+
align-items: center;
|
|
106
|
+
flex-shrink: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.lsdt-panel-header h3 {
|
|
110
|
+
margin: 0;
|
|
111
|
+
font-size: 15px;
|
|
112
|
+
font-weight: 600;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.lsdt-panel-actions {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.lsdt-readonly-badge {
|
|
122
|
+
font-size: 10px;
|
|
123
|
+
background: rgba(255, 255, 255, 0.2);
|
|
124
|
+
padding: 2px 8px;
|
|
125
|
+
border-radius: 4px;
|
|
126
|
+
font-weight: 500;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.lsdt-close-btn {
|
|
130
|
+
background: rgba(255, 255, 255, 0.2);
|
|
131
|
+
border: none;
|
|
132
|
+
color: white;
|
|
133
|
+
width: 24px;
|
|
134
|
+
height: 24px;
|
|
135
|
+
border-radius: 50%;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
font-size: 16px;
|
|
141
|
+
transition: background 0.2s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.lsdt-close-btn:hover {
|
|
145
|
+
background: rgba(255, 255, 255, 0.3);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.lsdt-panel-content {
|
|
149
|
+
padding: 0;
|
|
150
|
+
overflow-y: auto;
|
|
151
|
+
flex: 1;
|
|
152
|
+
min-height: 200px;
|
|
153
|
+
max-height: calc(100vh - 180px);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* json-edit-react overrides for dark panel */
|
|
157
|
+
#lsdt-json-editor-root {
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#lsdt-json-editor-root > div {
|
|
162
|
+
border-radius: 0 !important;
|
|
163
|
+
border: none !important;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Responsive */
|
|
167
|
+
@media (max-width: 768px) {
|
|
168
|
+
#lsdt-toolbar {
|
|
169
|
+
bottom: 10px;
|
|
170
|
+
right: 10px;
|
|
171
|
+
font-size: 12px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#lsdt-panel {
|
|
175
|
+
width: calc(100vw - 20px);
|
|
176
|
+
right: 10px;
|
|
177
|
+
top: 10px;
|
|
178
|
+
max-height: calc(100vh - 80px);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#lsdt-panel.lsdt-panel-left {
|
|
182
|
+
left: 10px;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React, { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
3
|
+
import { JsonEditor, githubDarkTheme, githubLightTheme, monoDarkTheme, monoLightTheme } from 'json-edit-react';
|
|
4
|
+
|
|
5
|
+
const themeMap: Record<string, object> = {
|
|
6
|
+
githubDark: githubDarkTheme,
|
|
7
|
+
githubLight: githubLightTheme,
|
|
8
|
+
monoDark: monoDarkTheme,
|
|
9
|
+
monoLight: monoLightTheme,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
class ErrorBoundary extends Component<
|
|
13
|
+
{ children: ReactNode },
|
|
14
|
+
{ error: Error | null }
|
|
15
|
+
> {
|
|
16
|
+
state = { error: null as Error | null };
|
|
17
|
+
static getDerivedStateFromError(error: Error) {
|
|
18
|
+
return { error };
|
|
19
|
+
}
|
|
20
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
21
|
+
console.error('[Legend State DevTools] React error:', error, info);
|
|
22
|
+
}
|
|
23
|
+
render() {
|
|
24
|
+
if (this.state.error) {
|
|
25
|
+
return React.createElement(
|
|
26
|
+
'pre',
|
|
27
|
+
{ style: { color: '#ff6b6b', padding: 16, fontSize: 12 } },
|
|
28
|
+
`DevTools Error: ${this.state.error.message}\n${this.state.error.stack}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return this.props.children;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface JsonEditorWrapperProps {
|
|
36
|
+
data: unknown;
|
|
37
|
+
onEdit: (newData: unknown) => void;
|
|
38
|
+
readOnly: boolean;
|
|
39
|
+
theme: string;
|
|
40
|
+
rootName: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function JsonEditorWrapper({
|
|
44
|
+
data,
|
|
45
|
+
onEdit,
|
|
46
|
+
readOnly,
|
|
47
|
+
theme,
|
|
48
|
+
rootName,
|
|
49
|
+
}: JsonEditorWrapperProps) {
|
|
50
|
+
const resolvedTheme = themeMap[theme] ?? githubDarkTheme;
|
|
51
|
+
return (
|
|
52
|
+
<JsonEditor
|
|
53
|
+
data={data as Record<string, unknown>}
|
|
54
|
+
setData={onEdit as any}
|
|
55
|
+
rootName={rootName}
|
|
56
|
+
theme={resolvedTheme as any}
|
|
57
|
+
collapse={2}
|
|
58
|
+
restrictEdit={readOnly}
|
|
59
|
+
restrictDelete={readOnly}
|
|
60
|
+
restrictAdd={readOnly}
|
|
61
|
+
restrictTypeSelection={readOnly ? true : undefined}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface JsonEditorBridge {
|
|
67
|
+
updateData: (data: unknown) => void;
|
|
68
|
+
destroy: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Wrapper component that receives data via a callback registration
|
|
72
|
+
function JsonEditorBridgeWrapper(props: {
|
|
73
|
+
initialData: unknown;
|
|
74
|
+
onEdit: (newData: unknown) => void;
|
|
75
|
+
readOnly: boolean;
|
|
76
|
+
theme: string;
|
|
77
|
+
rootName: string;
|
|
78
|
+
registerUpdater: (updater: (data: unknown) => void) => void;
|
|
79
|
+
}) {
|
|
80
|
+
const [data, setData] = useState<unknown>(props.initialData);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
props.registerUpdater((newData: unknown) => {
|
|
84
|
+
setData(newData);
|
|
85
|
+
});
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const handleEdit = (newData: unknown) => {
|
|
89
|
+
setData(newData);
|
|
90
|
+
props.onEdit(newData);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<JsonEditorWrapper
|
|
95
|
+
data={data}
|
|
96
|
+
onEdit={handleEdit}
|
|
97
|
+
readOnly={props.readOnly}
|
|
98
|
+
theme={props.theme}
|
|
99
|
+
rootName={props.rootName}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function mountJsonEditor(
|
|
105
|
+
container: HTMLElement,
|
|
106
|
+
options: {
|
|
107
|
+
initialData: unknown;
|
|
108
|
+
onEdit: (newData: unknown) => void;
|
|
109
|
+
readOnly: boolean;
|
|
110
|
+
theme: string;
|
|
111
|
+
rootName: string;
|
|
112
|
+
}
|
|
113
|
+
): JsonEditorBridge {
|
|
114
|
+
let root: Root | null = null;
|
|
115
|
+
let updaterFn: ((data: unknown) => void) | null = null;
|
|
116
|
+
|
|
117
|
+
root = createRoot(container);
|
|
118
|
+
root.render(
|
|
119
|
+
<ErrorBoundary>
|
|
120
|
+
<JsonEditorBridgeWrapper
|
|
121
|
+
initialData={options.initialData}
|
|
122
|
+
onEdit={options.onEdit}
|
|
123
|
+
readOnly={options.readOnly}
|
|
124
|
+
theme={options.theme}
|
|
125
|
+
rootName={options.rootName}
|
|
126
|
+
registerUpdater={(updater) => {
|
|
127
|
+
updaterFn = updater;
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
</ErrorBoundary>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
updateData: (data: unknown) => {
|
|
135
|
+
updaterFn?.(data);
|
|
136
|
+
},
|
|
137
|
+
destroy: () => {
|
|
138
|
+
if (root) {
|
|
139
|
+
root.unmount();
|
|
140
|
+
root = null;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { renderPanel, type PanelData } from './template-engine';
|
|
2
|
+
|
|
3
|
+
export class Panel {
|
|
4
|
+
private container: HTMLElement | null = null;
|
|
5
|
+
private visible = false;
|
|
6
|
+
private rootName: string;
|
|
7
|
+
private readOnly: boolean;
|
|
8
|
+
private onClose?: () => void;
|
|
9
|
+
private position: 'left' | 'right';
|
|
10
|
+
|
|
11
|
+
constructor(options: {
|
|
12
|
+
rootName?: string;
|
|
13
|
+
readOnly?: boolean;
|
|
14
|
+
onClose?: () => void;
|
|
15
|
+
position?: 'left' | 'right';
|
|
16
|
+
} = {}) {
|
|
17
|
+
this.rootName = options.rootName || 'state$';
|
|
18
|
+
this.readOnly = options.readOnly || false;
|
|
19
|
+
this.onClose = options.onClose;
|
|
20
|
+
this.position = options.position || 'right';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public toggle(): void {
|
|
24
|
+
if (this.visible) {
|
|
25
|
+
this.hide();
|
|
26
|
+
} else {
|
|
27
|
+
this.show();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public show(): void {
|
|
32
|
+
this.visible = true;
|
|
33
|
+
|
|
34
|
+
if (!this.container) {
|
|
35
|
+
this.container = document.createElement('div');
|
|
36
|
+
this.container.id = 'lsdt-panel';
|
|
37
|
+
if (this.position === 'left') {
|
|
38
|
+
this.container.classList.add('lsdt-panel-left');
|
|
39
|
+
}
|
|
40
|
+
document.body.appendChild(this.container);
|
|
41
|
+
this.attachEventListeners();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.render();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public hide(): void {
|
|
48
|
+
this.visible = false;
|
|
49
|
+
this.container?.remove();
|
|
50
|
+
this.container = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public isVisible(): boolean {
|
|
54
|
+
return this.visible;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public getEditorRoot(): HTMLElement | null {
|
|
58
|
+
return this.container?.querySelector('#lsdt-json-editor-root') || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private render(): void {
|
|
62
|
+
if (!this.container) return;
|
|
63
|
+
|
|
64
|
+
const data: PanelData = {
|
|
65
|
+
rootName: this.rootName,
|
|
66
|
+
readOnly: this.readOnly,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this.container.innerHTML = renderPanel(data);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private attachEventListeners(): void {
|
|
73
|
+
if (!this.container) return;
|
|
74
|
+
this.container.addEventListener('click', this.handleClick);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private handleClick = (e: Event): void => {
|
|
78
|
+
const target = e.target as HTMLElement;
|
|
79
|
+
const actionElement = target.closest('[data-action]');
|
|
80
|
+
if (!actionElement) return;
|
|
81
|
+
|
|
82
|
+
const action = actionElement.getAttribute('data-action');
|
|
83
|
+
if (action === 'close-panel') {
|
|
84
|
+
this.onClose?.();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
public unmount(): void {
|
|
89
|
+
if (this.container) {
|
|
90
|
+
this.container.removeEventListener('click', this.handleClick);
|
|
91
|
+
this.container.remove();
|
|
92
|
+
this.container = null;
|
|
93
|
+
}
|
|
94
|
+
this.visible = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared UI utilities for Legend State Dev Tools
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const STORAGE_PREFIX = 'lsdt';
|
|
6
|
+
|
|
7
|
+
export function escapeHtml(str: string): string {
|
|
8
|
+
const div = document.createElement('div');
|
|
9
|
+
div.textContent = str;
|
|
10
|
+
return div.innerHTML;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getStoredBoolean(key: string, defaultValue: boolean): boolean {
|
|
14
|
+
const stored = localStorage.getItem(`${STORAGE_PREFIX}-${key}`);
|
|
15
|
+
if (stored === null) return defaultValue;
|
|
16
|
+
return stored === 'true';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setStoredBoolean(key: string, value: boolean): void {
|
|
20
|
+
localStorage.setItem(`${STORAGE_PREFIX}-${key}`, String(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getStoredString(key: string, defaultValue: string): string {
|
|
24
|
+
return localStorage.getItem(`${STORAGE_PREFIX}-${key}`) || defaultValue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setStoredString(key: string, value: string): void {
|
|
28
|
+
localStorage.setItem(`${STORAGE_PREFIX}-${key}`, value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createCleanup(): {
|
|
32
|
+
add: (fn: () => void) => void;
|
|
33
|
+
run: () => void;
|
|
34
|
+
} {
|
|
35
|
+
const cleanupFns: (() => void)[] = [];
|
|
36
|
+
return {
|
|
37
|
+
add: (fn: () => void) => cleanupFns.push(fn),
|
|
38
|
+
run: () => {
|
|
39
|
+
cleanupFns.forEach((fn) => {
|
|
40
|
+
try {
|
|
41
|
+
fn();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error('[Legend State DevTools] Cleanup error:', e);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
cleanupFns.length = 0;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eta template engine wrapper for Legend State Dev Tools
|
|
3
|
+
*/
|
|
4
|
+
import { Eta } from 'eta';
|
|
5
|
+
|
|
6
|
+
import toolbarTemplate from './templates/toolbar.eta';
|
|
7
|
+
import panelTemplate from './templates/panel.eta';
|
|
8
|
+
|
|
9
|
+
const eta = new Eta({
|
|
10
|
+
autoEscape: true,
|
|
11
|
+
autoTrim: false,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const templates: Record<string, string> = {
|
|
15
|
+
toolbar: toolbarTemplate,
|
|
16
|
+
panel: panelTemplate,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function renderTemplate<T extends Record<string, unknown>>(
|
|
20
|
+
name: string,
|
|
21
|
+
data: T
|
|
22
|
+
): string {
|
|
23
|
+
const template = templates[name];
|
|
24
|
+
if (!template) {
|
|
25
|
+
console.error(`[LSDT] Template not found: ${name}`);
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return eta.renderString(template, data);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(`[LSDT] Error rendering template ${name}:`, error);
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToolbarData {
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
isMinimized: boolean;
|
|
39
|
+
panelVisible: boolean;
|
|
40
|
+
rootName: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderToolbar(data: ToolbarData): string {
|
|
44
|
+
return renderTemplate('toolbar', data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PanelData {
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
rootName: string;
|
|
50
|
+
readOnly: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderPanel(data: PanelData): string {
|
|
54
|
+
return renderTemplate('panel', data);
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div class="lsdt-panel-header">
|
|
2
|
+
<h3><%= it.rootName %></h3>
|
|
3
|
+
<div class="lsdt-panel-actions">
|
|
4
|
+
<% if (it.readOnly) { %>
|
|
5
|
+
<span class="lsdt-readonly-badge">Read-only</span>
|
|
6
|
+
<% } %>
|
|
7
|
+
<button class="lsdt-close-btn" data-action="close-panel" title="Close">×</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="lsdt-panel-content">
|
|
11
|
+
<div id="lsdt-json-editor-root"></div>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<div class="lsdt-toolbar-header">
|
|
2
|
+
<div class="lsdt-toolbar-title">
|
|
3
|
+
<span class="lsdt-toolbar-indicator"></span>
|
|
4
|
+
Legend State
|
|
5
|
+
</div>
|
|
6
|
+
<button
|
|
7
|
+
class="lsdt-toggle-btn <%= it.panelVisible ? 'active' : '' %>"
|
|
8
|
+
data-action="toggle-panel"
|
|
9
|
+
title="<%= it.panelVisible ? 'Hide panel' : 'Show panel' %>"
|
|
10
|
+
>
|
|
11
|
+
<%= it.panelVisible ? 'Hide' : 'Show' %> <%= it.rootName %>
|
|
12
|
+
</button>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { renderToolbar, type ToolbarData } from './template-engine';
|
|
2
|
+
import { getStoredBoolean, setStoredBoolean } from './shared-utils';
|
|
3
|
+
|
|
4
|
+
export class Toolbar {
|
|
5
|
+
private container: HTMLElement | null = null;
|
|
6
|
+
private isDragging = false;
|
|
7
|
+
private offsetX = 0;
|
|
8
|
+
private offsetY = 0;
|
|
9
|
+
private isMinimized: boolean = getStoredBoolean('toolbar-minimized', false);
|
|
10
|
+
private panelVisible = false;
|
|
11
|
+
|
|
12
|
+
private onTogglePanel?: () => void;
|
|
13
|
+
private rootName: string;
|
|
14
|
+
|
|
15
|
+
constructor(options: { onTogglePanel?: () => void; rootName?: string } = {}) {
|
|
16
|
+
this.onTogglePanel = options.onTogglePanel;
|
|
17
|
+
this.rootName = options.rootName || 'state$';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public mount(): void {
|
|
21
|
+
if (this.container) return;
|
|
22
|
+
|
|
23
|
+
this.container = document.createElement('div');
|
|
24
|
+
this.container.id = 'lsdt-toolbar';
|
|
25
|
+
if (this.isMinimized) {
|
|
26
|
+
this.container.classList.add('lsdt-toolbar-minimized');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
document.body.appendChild(this.container);
|
|
30
|
+
this.render();
|
|
31
|
+
this.attachEventListeners();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private render(): void {
|
|
35
|
+
if (!this.container) return;
|
|
36
|
+
|
|
37
|
+
const data: ToolbarData = {
|
|
38
|
+
isMinimized: this.isMinimized,
|
|
39
|
+
panelVisible: this.panelVisible,
|
|
40
|
+
rootName: this.rootName,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
this.container.innerHTML = renderToolbar(data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private attachEventListeners(): void {
|
|
47
|
+
if (!this.container) return;
|
|
48
|
+
|
|
49
|
+
this.container.addEventListener('click', this.handleClick);
|
|
50
|
+
this.container.addEventListener('mousedown', this.handleMouseDown);
|
|
51
|
+
document.addEventListener('mousemove', this.handleMouseMove);
|
|
52
|
+
document.addEventListener('mouseup', this.handleMouseUp);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private handleClick = (e: Event): void => {
|
|
56
|
+
const target = e.target as HTMLElement;
|
|
57
|
+
const actionElement = target.closest('[data-action]');
|
|
58
|
+
if (!actionElement) return;
|
|
59
|
+
|
|
60
|
+
const action = actionElement.getAttribute('data-action');
|
|
61
|
+
if (action === 'toggle-panel') {
|
|
62
|
+
this.onTogglePanel?.();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
private handleMouseDown = (e: MouseEvent): void => {
|
|
67
|
+
const target = e.target as HTMLElement;
|
|
68
|
+
if (target.tagName === 'BUTTON') return;
|
|
69
|
+
|
|
70
|
+
this.isDragging = true;
|
|
71
|
+
if (this.container) {
|
|
72
|
+
this.container.classList.add('dragging');
|
|
73
|
+
const rect = this.container.getBoundingClientRect();
|
|
74
|
+
this.offsetX = e.clientX - rect.left;
|
|
75
|
+
this.offsetY = e.clientY - rect.top;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
private handleMouseMove = (e: MouseEvent): void => {
|
|
80
|
+
if (!this.isDragging || !this.container) return;
|
|
81
|
+
|
|
82
|
+
this.container.style.left = `${e.clientX - this.offsetX}px`;
|
|
83
|
+
this.container.style.top = `${e.clientY - this.offsetY}px`;
|
|
84
|
+
this.container.style.right = 'auto';
|
|
85
|
+
this.container.style.bottom = 'auto';
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
private handleMouseUp = (): void => {
|
|
89
|
+
this.isDragging = false;
|
|
90
|
+
this.container?.classList.remove('dragging');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
public setPanelVisible(visible: boolean): void {
|
|
94
|
+
this.panelVisible = visible;
|
|
95
|
+
this.render();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public unmount(): void {
|
|
99
|
+
if (this.container) {
|
|
100
|
+
this.container.removeEventListener('click', this.handleClick);
|
|
101
|
+
this.container.removeEventListener('mousedown', this.handleMouseDown);
|
|
102
|
+
this.container.remove();
|
|
103
|
+
this.container = null;
|
|
104
|
+
}
|
|
105
|
+
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
106
|
+
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { copyFileSync } from 'fs';
|
|
4
|
+
import dts from 'vite-plugin-dts';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [
|
|
8
|
+
{
|
|
9
|
+
name: 'eta-raw-loader',
|
|
10
|
+
transform(code, id) {
|
|
11
|
+
if (id.endsWith('.eta')) {
|
|
12
|
+
return {
|
|
13
|
+
code: `export default ${JSON.stringify(code)};`,
|
|
14
|
+
map: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
dts({
|
|
20
|
+
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
21
|
+
exclude: ['src/**/*.test.ts'],
|
|
22
|
+
rollupTypes: true,
|
|
23
|
+
insertTypesEntry: true,
|
|
24
|
+
}),
|
|
25
|
+
{
|
|
26
|
+
name: 'copy-styles',
|
|
27
|
+
closeBundle() {
|
|
28
|
+
copyFileSync(
|
|
29
|
+
resolve(__dirname, 'src/styles.css'),
|
|
30
|
+
resolve(__dirname, 'dist/styles.css')
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
build: {
|
|
36
|
+
lib: {
|
|
37
|
+
entry: resolve(__dirname, 'src/index.ts'),
|
|
38
|
+
name: 'LegendStateDevTools',
|
|
39
|
+
formats: ['es', 'cjs'],
|
|
40
|
+
fileName: (format) => (format === 'es' ? 'index.mjs' : 'index.js'),
|
|
41
|
+
},
|
|
42
|
+
rollupOptions: {
|
|
43
|
+
external: [
|
|
44
|
+
'react',
|
|
45
|
+
'react-dom',
|
|
46
|
+
'react-dom/client',
|
|
47
|
+
'react/jsx-runtime',
|
|
48
|
+
'eta',
|
|
49
|
+
'json-edit-react',
|
|
50
|
+
'@legendapp/state',
|
|
51
|
+
],
|
|
52
|
+
output: {
|
|
53
|
+
globals: {
|
|
54
|
+
react: 'React',
|
|
55
|
+
'react-dom': 'ReactDOM',
|
|
56
|
+
'react-dom/client': 'ReactDOMClient',
|
|
57
|
+
eta: 'Eta',
|
|
58
|
+
'json-edit-react': 'JsonEditReact',
|
|
59
|
+
'@legendapp/state': 'LegendState',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
outDir: 'dist',
|
|
64
|
+
emptyOutDir: true,
|
|
65
|
+
sourcemap: true,
|
|
66
|
+
minify: 'esbuild',
|
|
67
|
+
},
|
|
68
|
+
resolve: {
|
|
69
|
+
extensions: ['.ts', '.tsx', '.js', '.jsx', '.eta'],
|
|
70
|
+
},
|
|
71
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"jsx": "react-jsx"
|
|
15
|
+
},
|
|
16
|
+
"exclude": ["examples/**/*", "node_modules/**/*", "**/dist/**/*"]
|
|
17
|
+
}
|