create-imagine 0.1.0
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/LICENSE +21 -0
- package/README.md +40 -0
- package/bin/create-imagine.js +407 -0
- package/package.json +19 -0
- package/templates/blank/README.md +96 -0
- package/templates/blank/gitignore +6 -0
- package/templates/blank/index.html +13 -0
- package/templates/blank/package.json +42 -0
- package/templates/blank/projects/example/figures/hello-world.tsx +45 -0
- package/templates/blank/projects/example/manifest.ts +44 -0
- package/templates/blank/projects/example/project.ts +16 -0
- package/templates/blank/projects/example/props.json +4 -0
- package/templates/blank/scripts/list.ts +46 -0
- package/templates/blank/scripts/projects.ts +43 -0
- package/templates/blank/scripts/render.ts +288 -0
- package/templates/blank/scripts/server.ts +117 -0
- package/templates/blank/src/core/__tests__/controls.test.ts +14 -0
- package/templates/blank/src/core/__tests__/manifest.test.ts +45 -0
- package/templates/blank/src/core/__tests__/props.test.ts +9 -0
- package/templates/blank/src/core/controls.ts +14 -0
- package/templates/blank/src/core/manifest.ts +134 -0
- package/templates/blank/src/core/props.ts +7 -0
- package/templates/blank/src/framework/Figure.tsx +34 -0
- package/templates/blank/src/framework/__tests__/sizing.test.ts +29 -0
- package/templates/blank/src/framework/charts/Axes.tsx +103 -0
- package/templates/blank/src/framework/charts/GridLines.tsx +59 -0
- package/templates/blank/src/framework/charts/Legend.tsx +31 -0
- package/templates/blank/src/framework/charts/Series.tsx +50 -0
- package/templates/blank/src/framework/charts/scales.ts +19 -0
- package/templates/blank/src/framework/diagrams/primitives.tsx +134 -0
- package/templates/blank/src/framework/layout/PanelGrid.tsx +60 -0
- package/templates/blank/src/framework/math/MathSvg.tsx +35 -0
- package/templates/blank/src/framework/math/mathjax.ts +64 -0
- package/templates/blank/src/framework/sizing.ts +28 -0
- package/templates/blank/src/framework/theme.ts +35 -0
- package/templates/blank/src/framework/types.ts +42 -0
- package/templates/blank/src/main.tsx +11 -0
- package/templates/blank/src/studio/StudioApp.tsx +130 -0
- package/templates/blank/src/studio/StudioRoot.tsx +14 -0
- package/templates/blank/src/studio/base64url.ts +8 -0
- package/templates/blank/src/studio/figureLoader.ts +30 -0
- package/templates/blank/src/studio/projectLoader.ts +40 -0
- package/templates/blank/src/studio/propsApi.ts +26 -0
- package/templates/blank/src/studio/routes/FigureView.tsx +365 -0
- package/templates/blank/src/studio/routes/ProjectHome.tsx +107 -0
- package/templates/blank/src/studio/routes/ProjectsHome.tsx +63 -0
- package/templates/blank/src/studio/routes/RenderView.tsx +123 -0
- package/templates/blank/src/studio/studio.css +540 -0
- package/templates/blank/src/studio/useProjectProps.ts +129 -0
- package/templates/blank/src/vite-env.d.ts +2 -0
- package/templates/blank/tsconfig.json +20 -0
- package/templates/blank/vite.config.ts +82 -0
- package/templates/blank/vitest.config.ts +8 -0
- package/templates/example/README.md +96 -0
- package/templates/example/gitignore +6 -0
- package/templates/example/index.html +13 -0
- package/templates/example/package.json +42 -0
- package/templates/example/projects/example/figures/ai-agent-architecture.tsx +133 -0
- package/templates/example/projects/example/figures/equation.tsx +29 -0
- package/templates/example/projects/example/figures/hello-world.tsx +45 -0
- package/templates/example/projects/example/figures/line-chart.tsx +80 -0
- package/templates/example/projects/example/figures/multi-panel.tsx +51 -0
- package/templates/example/projects/example/figures/pipeline-diagram.tsx +51 -0
- package/templates/example/projects/example/manifest.ts +161 -0
- package/templates/example/projects/example/project.ts +31 -0
- package/templates/example/projects/example/props.json +10 -0
- package/templates/example/public/projects/example/previews/ai-agent-architecture--default.png +0 -0
- package/templates/example/public/projects/example/previews/equation--default.png +0 -0
- package/templates/example/public/projects/example/previews/hello-world--default.png +0 -0
- package/templates/example/public/projects/example/previews/line-chart--default.png +0 -0
- package/templates/example/public/projects/example/previews/multi-panel--default.png +0 -0
- package/templates/example/public/projects/example/previews/pipeline-diagram--default.png +0 -0
- package/templates/example/scripts/list.ts +46 -0
- package/templates/example/scripts/projects.ts +43 -0
- package/templates/example/scripts/render.ts +288 -0
- package/templates/example/scripts/server.ts +117 -0
- package/templates/example/src/core/__tests__/controls.test.ts +14 -0
- package/templates/example/src/core/__tests__/manifest.test.ts +45 -0
- package/templates/example/src/core/__tests__/props.test.ts +9 -0
- package/templates/example/src/core/controls.ts +14 -0
- package/templates/example/src/core/manifest.ts +134 -0
- package/templates/example/src/core/props.ts +7 -0
- package/templates/example/src/framework/Figure.tsx +34 -0
- package/templates/example/src/framework/__tests__/sizing.test.ts +29 -0
- package/templates/example/src/framework/charts/Axes.tsx +103 -0
- package/templates/example/src/framework/charts/GridLines.tsx +59 -0
- package/templates/example/src/framework/charts/Legend.tsx +31 -0
- package/templates/example/src/framework/charts/Series.tsx +50 -0
- package/templates/example/src/framework/charts/scales.ts +19 -0
- package/templates/example/src/framework/diagrams/primitives.tsx +134 -0
- package/templates/example/src/framework/layout/PanelGrid.tsx +60 -0
- package/templates/example/src/framework/math/MathSvg.tsx +35 -0
- package/templates/example/src/framework/math/mathjax.ts +64 -0
- package/templates/example/src/framework/sizing.ts +28 -0
- package/templates/example/src/framework/theme.ts +35 -0
- package/templates/example/src/framework/types.ts +42 -0
- package/templates/example/src/main.tsx +11 -0
- package/templates/example/src/studio/StudioApp.tsx +130 -0
- package/templates/example/src/studio/StudioRoot.tsx +14 -0
- package/templates/example/src/studio/base64url.ts +8 -0
- package/templates/example/src/studio/figureLoader.ts +30 -0
- package/templates/example/src/studio/projectLoader.ts +40 -0
- package/templates/example/src/studio/propsApi.ts +26 -0
- package/templates/example/src/studio/routes/FigureView.tsx +365 -0
- package/templates/example/src/studio/routes/ProjectHome.tsx +107 -0
- package/templates/example/src/studio/routes/ProjectsHome.tsx +63 -0
- package/templates/example/src/studio/routes/RenderView.tsx +123 -0
- package/templates/example/src/studio/studio.css +540 -0
- package/templates/example/src/studio/useProjectProps.ts +129 -0
- package/templates/example/src/vite-env.d.ts +2 -0
- package/templates/example/tsconfig.json +20 -0
- package/templates/example/vite.config.ts +82 -0
- package/templates/example/vitest.config.ts +8 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import { inferControlsFromProps } from '../../core/controls';
|
|
4
|
+
import type { FigureControl, FigureVariant, ProjectDefinition } from '../../core/manifest';
|
|
5
|
+
import { resolveSize } from '../../framework/sizing';
|
|
6
|
+
import { loadFigureComponent } from '../figureLoader';
|
|
7
|
+
import { loadProject } from '../projectLoader';
|
|
8
|
+
import { useProjectProps } from '../useProjectProps';
|
|
9
|
+
|
|
10
|
+
type ZoomMode = { kind: 'fit' } | { kind: 'percent'; value: number };
|
|
11
|
+
|
|
12
|
+
function useElementSize(ref: React.RefObject<HTMLElement>) {
|
|
13
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const el = ref.current;
|
|
17
|
+
if (!el) return;
|
|
18
|
+
|
|
19
|
+
const ro = new ResizeObserver((entries) => {
|
|
20
|
+
const entry = entries[0];
|
|
21
|
+
if (!entry) return;
|
|
22
|
+
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
|
23
|
+
});
|
|
24
|
+
ro.observe(el);
|
|
25
|
+
return () => ro.disconnect();
|
|
26
|
+
}, [ref]);
|
|
27
|
+
|
|
28
|
+
return size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function coerceZoomValue(value: number) {
|
|
32
|
+
if (!Number.isFinite(value)) return 100;
|
|
33
|
+
return Math.max(5, Math.min(800, Math.round(value)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FigureView() {
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const params = useParams();
|
|
39
|
+
const projectId = params.projectId ?? '';
|
|
40
|
+
const figureId = params.figureId ?? '';
|
|
41
|
+
const variantId = params.variantId;
|
|
42
|
+
const [FigureComponent, setFigureComponent] = useState<React.ComponentType<any> | null>(null);
|
|
43
|
+
const [zoom, setZoom] = useState<ZoomMode>({ kind: 'fit' });
|
|
44
|
+
const [checker, setChecker] = useState(false);
|
|
45
|
+
const [project, setProject] = useState<ProjectDefinition | null>(null);
|
|
46
|
+
const [projectError, setProjectError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!projectId) return;
|
|
50
|
+
setProject(null);
|
|
51
|
+
setProjectError(null);
|
|
52
|
+
loadProject(projectId).then(setProject, (err) => setProjectError(String(err?.message ?? err)));
|
|
53
|
+
}, [projectId]);
|
|
54
|
+
|
|
55
|
+
const fig = project?.figures.find((f) => f.id === figureId);
|
|
56
|
+
const variant: FigureVariant | undefined = useMemo(() => {
|
|
57
|
+
if (!fig) return undefined;
|
|
58
|
+
if (variantId) return fig.variants.find((v) => v.id === variantId) ?? fig.variants[0];
|
|
59
|
+
return fig.variants[0];
|
|
60
|
+
}, [fig, variantId]);
|
|
61
|
+
|
|
62
|
+
const propsState = useProjectProps(projectId);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!fig) return;
|
|
66
|
+
setFigureComponent(null);
|
|
67
|
+
loadFigureComponent(projectId, fig.moduleKey).then((Component) => setFigureComponent(() => Component), (err) => {
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.error(err);
|
|
70
|
+
setFigureComponent(() => () => (
|
|
71
|
+
<div className="empty">
|
|
72
|
+
<div className="emptyTitle">Failed to load figure module</div>
|
|
73
|
+
<div className="emptyBody mono">{String(err?.message ?? err)}</div>
|
|
74
|
+
</div>
|
|
75
|
+
));
|
|
76
|
+
});
|
|
77
|
+
}, [projectId, fig?.moduleKey]);
|
|
78
|
+
|
|
79
|
+
const surfaceRef = useRef<HTMLDivElement | null>(null);
|
|
80
|
+
const surfaceSize = useElementSize(surfaceRef);
|
|
81
|
+
|
|
82
|
+
const size = useMemo(() => {
|
|
83
|
+
if (!fig || !variant) return { width: 100, height: 100, source: { unit: 'px' as const, width: 100, height: 100 } };
|
|
84
|
+
return resolveSize(variant.size ?? fig.size);
|
|
85
|
+
}, [fig, variant]);
|
|
86
|
+
|
|
87
|
+
const scale = useMemo(() => {
|
|
88
|
+
if (zoom.kind === 'percent') return zoom.value / 100;
|
|
89
|
+
const pad = 24;
|
|
90
|
+
const w = Math.max(1, surfaceSize.width - pad * 2);
|
|
91
|
+
const h = Math.max(1, surfaceSize.height - pad * 2);
|
|
92
|
+
return Math.max(0.05, Math.min(8, Math.min(w / size.width, h / size.height)));
|
|
93
|
+
}, [zoom, surfaceSize.width, surfaceSize.height, size.width, size.height]);
|
|
94
|
+
|
|
95
|
+
if (projectError) {
|
|
96
|
+
return (
|
|
97
|
+
<div className="page">
|
|
98
|
+
<div className="empty">
|
|
99
|
+
<div className="emptyTitle">Failed to load project</div>
|
|
100
|
+
<div className="emptyBody mono">{projectError}</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!fig || !variant) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="page">
|
|
109
|
+
<div className="empty">
|
|
110
|
+
<div className="emptyTitle">Figure not found</div>
|
|
111
|
+
<div className="emptyBody">
|
|
112
|
+
Go back to <Link to={`/project/${encodeURIComponent(projectId)}`}>project</Link>.
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const controls: FigureControl[] = (() => {
|
|
120
|
+
const explicit = [...(fig.controls ?? []), ...(variant.controls ?? [])];
|
|
121
|
+
if (explicit.length) return explicit;
|
|
122
|
+
return inferControlsFromProps(variant.props ?? {});
|
|
123
|
+
})();
|
|
124
|
+
|
|
125
|
+
// const size = ... (already defined above)
|
|
126
|
+
const background = variant.background ?? 'white';
|
|
127
|
+
const variantOverrides = propsState.getVariantOverrides(fig.id, variant.id);
|
|
128
|
+
const effectiveVariantProps = { ...(variant.props ?? {}), ...variantOverrides };
|
|
129
|
+
|
|
130
|
+
const props = {
|
|
131
|
+
width: size.width,
|
|
132
|
+
height: size.height,
|
|
133
|
+
background,
|
|
134
|
+
...(effectiveVariantProps as any)
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const mmText = size.mm && size.dpi ? `${size.mm.width}×${size.mm.height} mm @ ${size.dpi}dpi` : null;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="page figurePage">
|
|
141
|
+
<div className="pageHeader figureHeader">
|
|
142
|
+
<div className="figureHeaderLeft">
|
|
143
|
+
<div className="pageTitle">{fig.title}</div>
|
|
144
|
+
<div className="pageSubtitle">
|
|
145
|
+
<span className="mono">{fig.id}</span> • {size.width}×{size.height} px{mmText ? ` • ${mmText}` : ''}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="figureHeaderRight">
|
|
149
|
+
<label className="label">
|
|
150
|
+
Variant
|
|
151
|
+
<select
|
|
152
|
+
className="select"
|
|
153
|
+
value={variant.id}
|
|
154
|
+
onChange={(e) =>
|
|
155
|
+
navigate(
|
|
156
|
+
`/project/${encodeURIComponent(projectId)}/figure/${encodeURIComponent(fig.id)}/${encodeURIComponent(
|
|
157
|
+
e.target.value
|
|
158
|
+
)}`
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
>
|
|
162
|
+
{fig.variants.map((v) => (
|
|
163
|
+
<option key={v.id} value={v.id}>
|
|
164
|
+
{v.title ? `${v.title} (${v.id})` : v.id}
|
|
165
|
+
</option>
|
|
166
|
+
))}
|
|
167
|
+
</select>
|
|
168
|
+
</label>
|
|
169
|
+
|
|
170
|
+
<div className="toolbarGroup">
|
|
171
|
+
<button className="btn btnSmall" onClick={() => setZoom({ kind: 'fit' })}>
|
|
172
|
+
Fit
|
|
173
|
+
</button>
|
|
174
|
+
<button className="btn btnSmall" onClick={() => setZoom({ kind: 'percent', value: 100 })}>
|
|
175
|
+
100%
|
|
176
|
+
</button>
|
|
177
|
+
<button className="btn btnSmall" onClick={() => setZoom({ kind: 'percent', value: 200 })}>
|
|
178
|
+
200%
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<label className="label">
|
|
183
|
+
Zoom
|
|
184
|
+
<input
|
|
185
|
+
className="input"
|
|
186
|
+
type="number"
|
|
187
|
+
min={5}
|
|
188
|
+
max={800}
|
|
189
|
+
step={5}
|
|
190
|
+
value={zoom.kind === 'percent' ? zoom.value : Math.round(scale * 100)}
|
|
191
|
+
onChange={(e) => setZoom({ kind: 'percent', value: coerceZoomValue(Number(e.target.value)) })}
|
|
192
|
+
/>
|
|
193
|
+
</label>
|
|
194
|
+
|
|
195
|
+
<label className="checkbox">
|
|
196
|
+
<input type="checkbox" checked={checker} onChange={(e) => setChecker(e.target.checked)} /> Checkerboard
|
|
197
|
+
</label>
|
|
198
|
+
|
|
199
|
+
<Link
|
|
200
|
+
className="btn btnSmall"
|
|
201
|
+
to={`/render/${encodeURIComponent(projectId)}/${encodeURIComponent(fig.id)}/${encodeURIComponent(variant.id)}`}
|
|
202
|
+
target="_blank"
|
|
203
|
+
>
|
|
204
|
+
Render route
|
|
205
|
+
</Link>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div className="figureBody">
|
|
210
|
+
<div className={`previewSurface ${checker ? 'checker' : ''}`} ref={surfaceRef}>
|
|
211
|
+
<div className="previewScale" style={{ transform: `scale(${scale})` }}>
|
|
212
|
+
<div id="figure-root" style={{ width: size.width, height: size.height }}>
|
|
213
|
+
{FigureComponent ? <FigureComponent {...props} /> : <div className="loading">Loading…</div>}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<aside className="controlsPanel" aria-label="Controls">
|
|
219
|
+
<div className="controlsHeader">
|
|
220
|
+
<div className="controlsTitle">Controls</div>
|
|
221
|
+
<div className="controlsActions">
|
|
222
|
+
<button
|
|
223
|
+
className="btn btnSmall"
|
|
224
|
+
onClick={() => propsState.resetVariantOverrides(fig.id, variant.id)}
|
|
225
|
+
title="Clear saved overrides for this variant"
|
|
226
|
+
>
|
|
227
|
+
Reset
|
|
228
|
+
</button>
|
|
229
|
+
<button
|
|
230
|
+
className="btn btnSmall"
|
|
231
|
+
onClick={async () => {
|
|
232
|
+
const json = JSON.stringify(variantOverrides, null, 2);
|
|
233
|
+
try {
|
|
234
|
+
await navigator.clipboard.writeText(json);
|
|
235
|
+
} catch {
|
|
236
|
+
window.prompt('Copy overrides JSON:', json);
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
title="Copy overrides JSON"
|
|
240
|
+
>
|
|
241
|
+
Copy JSON
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div className="controlsStatus">
|
|
247
|
+
{propsState.readOnly ? (
|
|
248
|
+
<span className="statusMuted" title={propsState.loadError ?? undefined}>
|
|
249
|
+
Saving disabled
|
|
250
|
+
</span>
|
|
251
|
+
) : propsState.saveStatus === 'saving' ? (
|
|
252
|
+
<span className="statusMuted">Saving…</span>
|
|
253
|
+
) : propsState.saveStatus === 'saved' ? (
|
|
254
|
+
<span className="statusOk">Saved</span>
|
|
255
|
+
) : propsState.saveStatus === 'error' ? (
|
|
256
|
+
<span className="statusErr">Save failed</span>
|
|
257
|
+
) : (
|
|
258
|
+
<span className="statusMuted">Edits auto-save</span>
|
|
259
|
+
)}
|
|
260
|
+
{propsState.saveError ? <div className="statusErr mono">{propsState.saveError}</div> : null}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{controls.length ? (
|
|
264
|
+
<div className="controlsGrid">
|
|
265
|
+
{controls.map((c, idx) => {
|
|
266
|
+
const key = c.key;
|
|
267
|
+
const label = c.label ?? key;
|
|
268
|
+
const currentValue = (effectiveVariantProps as any)[key];
|
|
269
|
+
|
|
270
|
+
if (c.kind === 'text') {
|
|
271
|
+
const value = typeof currentValue === 'string' ? currentValue : currentValue == null ? '' : String(currentValue);
|
|
272
|
+
return (
|
|
273
|
+
<label key={`${key}:${idx}`} className="control">
|
|
274
|
+
<div className="controlLabel">{label}</div>
|
|
275
|
+
{c.multiline ? (
|
|
276
|
+
<textarea
|
|
277
|
+
className="textarea"
|
|
278
|
+
rows={5}
|
|
279
|
+
placeholder={c.placeholder}
|
|
280
|
+
value={value}
|
|
281
|
+
onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.value)}
|
|
282
|
+
/>
|
|
283
|
+
) : (
|
|
284
|
+
<input
|
|
285
|
+
className="input"
|
|
286
|
+
type="text"
|
|
287
|
+
placeholder={c.placeholder}
|
|
288
|
+
value={value}
|
|
289
|
+
onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.value)}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
</label>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (c.kind === 'number') {
|
|
297
|
+
const value = typeof currentValue === 'number' && Number.isFinite(currentValue) ? String(currentValue) : '';
|
|
298
|
+
return (
|
|
299
|
+
<label key={`${key}:${idx}`} className="control">
|
|
300
|
+
<div className="controlLabel">{label}</div>
|
|
301
|
+
<input
|
|
302
|
+
className="input"
|
|
303
|
+
type="number"
|
|
304
|
+
min={c.min}
|
|
305
|
+
max={c.max}
|
|
306
|
+
step={c.step}
|
|
307
|
+
value={value}
|
|
308
|
+
onChange={(e) => {
|
|
309
|
+
const s = e.target.value;
|
|
310
|
+
if (!s) propsState.setVariantOverride(fig.id, variant.id, key, undefined);
|
|
311
|
+
else {
|
|
312
|
+
const n = Number(s);
|
|
313
|
+
propsState.setVariantOverride(fig.id, variant.id, key, Number.isFinite(n) ? n : undefined);
|
|
314
|
+
}
|
|
315
|
+
}}
|
|
316
|
+
/>
|
|
317
|
+
</label>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (c.kind === 'boolean') {
|
|
322
|
+
const checked = Boolean(currentValue);
|
|
323
|
+
return (
|
|
324
|
+
<label key={`${key}:${idx}`} className="control controlCheckbox">
|
|
325
|
+
<input
|
|
326
|
+
type="checkbox"
|
|
327
|
+
checked={checked}
|
|
328
|
+
onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.checked)}
|
|
329
|
+
/>
|
|
330
|
+
<div className="controlLabel">{label}</div>
|
|
331
|
+
</label>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (c.kind === 'select') {
|
|
336
|
+
const value = typeof currentValue === 'string' ? currentValue : currentValue == null ? '' : String(currentValue);
|
|
337
|
+
return (
|
|
338
|
+
<label key={`${key}:${idx}`} className="control">
|
|
339
|
+
<div className="controlLabel">{label}</div>
|
|
340
|
+
<select
|
|
341
|
+
className="select"
|
|
342
|
+
value={value}
|
|
343
|
+
onChange={(e) => propsState.setVariantOverride(fig.id, variant.id, key, e.target.value)}
|
|
344
|
+
>
|
|
345
|
+
{c.options.map((opt) => (
|
|
346
|
+
<option key={opt.value} value={opt.value}>
|
|
347
|
+
{opt.label}
|
|
348
|
+
</option>
|
|
349
|
+
))}
|
|
350
|
+
</select>
|
|
351
|
+
</label>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return null;
|
|
356
|
+
})}
|
|
357
|
+
</div>
|
|
358
|
+
) : (
|
|
359
|
+
<div className="controlsEmpty">No editable props found.</div>
|
|
360
|
+
)}
|
|
361
|
+
</aside>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Link, useParams } from 'react-router-dom';
|
|
3
|
+
import type { ProjectDefinition } from '../../core/manifest';
|
|
4
|
+
import { resolveSize } from '../../framework/sizing';
|
|
5
|
+
import { loadProject } from '../projectLoader';
|
|
6
|
+
|
|
7
|
+
export function ProjectHome() {
|
|
8
|
+
const params = useParams();
|
|
9
|
+
const projectId = params.projectId ?? '';
|
|
10
|
+
const [project, setProject] = useState<ProjectDefinition | null>(null);
|
|
11
|
+
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!projectId) return;
|
|
15
|
+
setProject(null);
|
|
16
|
+
setError(null);
|
|
17
|
+
loadProject(projectId).then(setProject, (err) => setError(String(err?.message ?? err)));
|
|
18
|
+
}, [projectId]);
|
|
19
|
+
|
|
20
|
+
const examples = project?.examples ?? [];
|
|
21
|
+
|
|
22
|
+
const figures = useMemo(() => project?.figures ?? [], [project]);
|
|
23
|
+
|
|
24
|
+
if (error) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="page">
|
|
27
|
+
<div className="empty">
|
|
28
|
+
<div className="emptyTitle">Failed to load project</div>
|
|
29
|
+
<div className="emptyBody mono">{error}</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!project) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="page">
|
|
38
|
+
<div className="loading">Loading…</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="page">
|
|
45
|
+
<div className="pageHeader">
|
|
46
|
+
<div>
|
|
47
|
+
<div className="pageTitle">{project.title}</div>
|
|
48
|
+
<div className="pageSubtitle">
|
|
49
|
+
<span className="mono">{project.id}</span>
|
|
50
|
+
{project.description ? ` • ${project.description}` : ''}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{examples.length ? (
|
|
56
|
+
<div className="section">
|
|
57
|
+
<div className="sectionTitle">Gallery</div>
|
|
58
|
+
<div className="galleryGrid">
|
|
59
|
+
{examples.map((ex) => (
|
|
60
|
+
<Link
|
|
61
|
+
key={`${ex.figureId}/${ex.variantId}`}
|
|
62
|
+
className="galleryItem"
|
|
63
|
+
to={`/project/${encodeURIComponent(project.id)}/figure/${encodeURIComponent(ex.figureId)}/${encodeURIComponent(
|
|
64
|
+
ex.variantId
|
|
65
|
+
)}`}
|
|
66
|
+
>
|
|
67
|
+
<img className="galleryImg" src={ex.src} alt={ex.caption ?? `${ex.figureId}/${ex.variantId}`} loading="lazy" />
|
|
68
|
+
<div className="galleryCaption">{ex.caption ?? `${ex.figureId}/${ex.variantId}`}</div>
|
|
69
|
+
</Link>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
74
|
+
|
|
75
|
+
<div className="section">
|
|
76
|
+
<div className="sectionTitle">Figures</div>
|
|
77
|
+
<div className="cardGrid">
|
|
78
|
+
{figures.map((f) => {
|
|
79
|
+
const r = resolveSize(f.size);
|
|
80
|
+
const mmText = r.mm && r.dpi ? ` (${r.mm.width}×${r.mm.height} mm @ ${r.dpi}dpi)` : '';
|
|
81
|
+
return (
|
|
82
|
+
<Link
|
|
83
|
+
key={f.id}
|
|
84
|
+
to={`/project/${encodeURIComponent(project.id)}/figure/${encodeURIComponent(f.id)}`}
|
|
85
|
+
className="card"
|
|
86
|
+
>
|
|
87
|
+
<div className="cardTitle">{f.title}</div>
|
|
88
|
+
<div className="cardBody">
|
|
89
|
+
<div className="cardMeta">
|
|
90
|
+
<span className="mono">{f.id}</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="cardMeta">
|
|
93
|
+
{r.width}×{r.height} px{mmText}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="cardMeta">
|
|
96
|
+
{f.variants.length} variant{f.variants.length === 1 ? '' : 's'}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</Link>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import type { ProjectDefinition } from '../../core/manifest';
|
|
4
|
+
import { loadAllProjects } from '../projectLoader';
|
|
5
|
+
|
|
6
|
+
export function ProjectsHome() {
|
|
7
|
+
const [projects, setProjects] = useState<ProjectDefinition[] | null>(null);
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
loadAllProjects().then(setProjects, (err) => setError(String(err?.message ?? err)));
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="page">
|
|
16
|
+
<div className="pageHeader">
|
|
17
|
+
<div>
|
|
18
|
+
<div className="pageTitle">Projects</div>
|
|
19
|
+
<div className="pageSubtitle">
|
|
20
|
+
Create a folder in <span className="mono">{'projects/<id>'}</span> to add a new project (restart dev server).
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
{error ? (
|
|
26
|
+
<div className="empty">
|
|
27
|
+
<div className="emptyTitle">Failed to load projects</div>
|
|
28
|
+
<div className="emptyBody mono">{error}</div>
|
|
29
|
+
</div>
|
|
30
|
+
) : null}
|
|
31
|
+
|
|
32
|
+
<div className="cardGrid">
|
|
33
|
+
{(projects ?? []).map((p) => (
|
|
34
|
+
<Link key={p.id} to={`/project/${encodeURIComponent(p.id)}`} className="card projectCard">
|
|
35
|
+
<div className="cardTitle">{p.title}</div>
|
|
36
|
+
<div className="cardBody">
|
|
37
|
+
<div className="cardMeta">
|
|
38
|
+
<span className="mono">{p.id}</span>
|
|
39
|
+
<span className="dot">•</span>
|
|
40
|
+
<span>{p.figures.length} figures</span>
|
|
41
|
+
</div>
|
|
42
|
+
{p.description ? <div className="cardMeta">{p.description}</div> : null}
|
|
43
|
+
{p.examples?.length ? (
|
|
44
|
+
<div className="thumbRow" aria-label="Example previews">
|
|
45
|
+
{p.examples.slice(0, 3).map((ex) => (
|
|
46
|
+
<img
|
|
47
|
+
key={`${ex.figureId}/${ex.variantId}`}
|
|
48
|
+
className="thumb"
|
|
49
|
+
src={ex.src}
|
|
50
|
+
alt={ex.caption ?? `${ex.figureId}/${ex.variantId}`}
|
|
51
|
+
loading="lazy"
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
) : null}
|
|
56
|
+
</div>
|
|
57
|
+
</Link>
|
|
58
|
+
))}
|
|
59
|
+
{!projects && !error ? <div className="loading">Loading…</div> : null}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useLocation, useParams } from 'react-router-dom';
|
|
3
|
+
import type { FigureVariant, ProjectDefinition } from '../../core/manifest';
|
|
4
|
+
import { resolveSize } from '../../framework/sizing';
|
|
5
|
+
import { loadFigureComponent } from '../figureLoader';
|
|
6
|
+
import { waitForMathTasks } from '../../framework/math/mathjax';
|
|
7
|
+
import { loadProject } from '../projectLoader';
|
|
8
|
+
import { base64UrlDecodeToString } from '../base64url';
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
__IMAGINE_READY__?: boolean;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setPageBackground(bg: 'white' | 'transparent') {
|
|
17
|
+
const val = bg === 'transparent' ? 'transparent' : '#ffffff';
|
|
18
|
+
document.documentElement.style.background = val;
|
|
19
|
+
document.body.style.background = val;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function waitForPendingMathDom(maxMs: number) {
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
while (Date.now() - start < maxMs) {
|
|
25
|
+
const pending = document.querySelector('[data-imagine-math="pending"]');
|
|
26
|
+
if (!pending) return;
|
|
27
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function RenderView() {
|
|
32
|
+
const location = useLocation();
|
|
33
|
+
const params = useParams();
|
|
34
|
+
const projectId = params.projectId ?? '';
|
|
35
|
+
const figureId = params.figureId ?? '';
|
|
36
|
+
const variantId = params.variantId;
|
|
37
|
+
const [FigureComponent, setFigureComponent] = useState<React.ComponentType<any> | null>(null);
|
|
38
|
+
const [project, setProject] = useState<ProjectDefinition | null>(null);
|
|
39
|
+
|
|
40
|
+
const propsOverride = useMemo(() => {
|
|
41
|
+
const encoded = new URLSearchParams(location.search).get('props');
|
|
42
|
+
if (!encoded) return null;
|
|
43
|
+
try {
|
|
44
|
+
const decoded = base64UrlDecodeToString(encoded);
|
|
45
|
+
const parsed = JSON.parse(decoded);
|
|
46
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
47
|
+
return parsed as Record<string, unknown>;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}, [location.search]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!projectId) return;
|
|
55
|
+
setProject(null);
|
|
56
|
+
loadProject(projectId).then(setProject, (err) => {
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.error(err);
|
|
59
|
+
setProject(() => null);
|
|
60
|
+
window.__IMAGINE_READY__ = true;
|
|
61
|
+
});
|
|
62
|
+
}, [projectId]);
|
|
63
|
+
|
|
64
|
+
const fig = project?.figures.find((f) => f.id === figureId);
|
|
65
|
+
const variant: FigureVariant | undefined = useMemo(() => {
|
|
66
|
+
if (!fig) return undefined;
|
|
67
|
+
if (variantId) return fig.variants.find((v) => v.id === variantId) ?? fig.variants[0];
|
|
68
|
+
return fig.variants[0];
|
|
69
|
+
}, [fig, variantId]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
window.__IMAGINE_READY__ = false;
|
|
73
|
+
if (!fig) return;
|
|
74
|
+
setFigureComponent(null);
|
|
75
|
+
loadFigureComponent(projectId, fig.moduleKey).then((Component) => setFigureComponent(() => Component), (err) => {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.error(err);
|
|
78
|
+
setFigureComponent(() => () => null);
|
|
79
|
+
});
|
|
80
|
+
}, [projectId, fig?.moduleKey]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!fig || !variant || !FigureComponent) return;
|
|
84
|
+
(async () => {
|
|
85
|
+
window.__IMAGINE_READY__ = false;
|
|
86
|
+
document.body.style.margin = '0';
|
|
87
|
+
const bg = (variant?.background ?? 'white') as 'white' | 'transparent';
|
|
88
|
+
setPageBackground(bg);
|
|
89
|
+
|
|
90
|
+
await (document as any).fonts?.ready?.catch(() => undefined);
|
|
91
|
+
|
|
92
|
+
// Let figure effects run at least once, then wait for math (if any).
|
|
93
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
94
|
+
await Promise.race([waitForMathTasks(), new Promise((r) => setTimeout(r, 30_000))]);
|
|
95
|
+
await waitForPendingMathDom(30_000);
|
|
96
|
+
|
|
97
|
+
await new Promise(requestAnimationFrame);
|
|
98
|
+
window.__IMAGINE_READY__ = true;
|
|
99
|
+
})().catch((err) => {
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.error(err);
|
|
102
|
+
window.__IMAGINE_READY__ = true;
|
|
103
|
+
});
|
|
104
|
+
}, [fig?.id, variant?.id, variant?.background, FigureComponent, location.search]);
|
|
105
|
+
|
|
106
|
+
if (!fig || !variant) return null;
|
|
107
|
+
|
|
108
|
+
const size = resolveSize(variant.size ?? fig.size);
|
|
109
|
+
const background = variant.background ?? 'white';
|
|
110
|
+
const mergedProps = { ...(variant.props ?? {}), ...(propsOverride ?? {}) };
|
|
111
|
+
const props = {
|
|
112
|
+
width: size.width,
|
|
113
|
+
height: size.height,
|
|
114
|
+
background,
|
|
115
|
+
...(mergedProps as any)
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div id="figure-root" style={{ width: size.width, height: size.height, overflow: 'hidden' }}>
|
|
120
|
+
{FigureComponent ? <FigureComponent {...props} /> : null}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|