@westopp/windo 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 +146 -0
- package/dist/chunk-5RM2VYAM.js +150 -0
- package/dist/chunk-5RM2VYAM.js.map +1 -0
- package/dist/cli.cjs +303 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +138 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +219 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.cjs +185 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.d.cts +11 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +11 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +95 -0
- package/src/cli/index.ts +160 -0
- package/src/client/App.tsx +310 -0
- package/src/client/Canvas.tsx +358 -0
- package/src/client/Inspector.tsx +586 -0
- package/src/client/Sidebar.tsx +108 -0
- package/src/client/bridge.ts +124 -0
- package/src/client/chrome.css +1966 -0
- package/src/client/icons.tsx +110 -0
- package/src/client/index.ts +15 -0
- package/src/client/internal-types.ts +147 -0
- package/src/client/persist.ts +38 -0
- package/src/define-config.ts +54 -0
- package/src/descriptor.test.ts +59 -0
- package/src/descriptor.ts +185 -0
- package/src/globals.d.ts +9 -0
- package/src/index.ts +54 -0
- package/src/plugin/index.ts +181 -0
- package/src/preview/ctx.ts +43 -0
- package/src/preview/index.ts +283 -0
- package/src/preview/preview.css +81 -0
- package/src/preview/registry.ts +159 -0
- package/src/preview/render.tsx +90 -0
- package/src/preview/virtual.d.ts +8 -0
- package/src/protocol.ts +59 -0
- package/src/types.ts +319 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Workbench shell — topbar, sidebar, canvas, inspector, tweaks. Owns all global
|
|
2
|
+
// chrome state (persisted to localStorage), wires it to the iframe bridge, and
|
|
3
|
+
// composes the three panes. Ports the markup/class names from the mock app.jsx;
|
|
4
|
+
// the difference is that windo renders components in an IFRAME (driven through
|
|
5
|
+
// the bridge) instead of in-document.
|
|
6
|
+
|
|
7
|
+
import type { CSSProperties } from 'react'
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
9
|
+
import type { WindoEnvState } from '../types'
|
|
10
|
+
import { useWindoBridge } from './bridge'
|
|
11
|
+
import { Canvas } from './Canvas'
|
|
12
|
+
import { Inspector } from './Inspector'
|
|
13
|
+
import { Icons } from './icons'
|
|
14
|
+
import type { CanvasGridOpts, ChromeEnv, InspectorPosition, ThemeMode } from './internal-types'
|
|
15
|
+
import { CANVAS_GRID_DEFAULTS } from './internal-types'
|
|
16
|
+
import { loadJSON, loadString, saveJSON, saveString } from './persist'
|
|
17
|
+
import { Sidebar } from './Sidebar'
|
|
18
|
+
|
|
19
|
+
// The plugin injects this via `define` ('./__windo/iframe' in dev, where the
|
|
20
|
+
// middleware serves it routelessly; './__windo/iframe.html' in a build, where
|
|
21
|
+
// it's a real emitted document). Fall back to the dev path if it's absent.
|
|
22
|
+
const IFRAME_SRC = typeof __WINDO_IFRAME_SRC__ === 'string' ? __WINDO_IFRAME_SRC__ : './__windo/iframe'
|
|
23
|
+
|
|
24
|
+
const DEFAULT_ENV: ChromeEnv = {
|
|
25
|
+
colorScheme: 'light',
|
|
26
|
+
reducedMotion: false,
|
|
27
|
+
direction: 'ltr',
|
|
28
|
+
locale: 'en',
|
|
29
|
+
contexts: {},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function viewportName(width: number): 'mobile' | 'tablet' | 'desktop' {
|
|
33
|
+
return width < 640 ? 'mobile' : width < 1024 ? 'tablet' : 'desktop'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function App() {
|
|
37
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
38
|
+
const bridge = useWindoBridge(iframeRef)
|
|
39
|
+
|
|
40
|
+
const [theme, setTheme] = useState<ThemeMode>(() => loadString('windo:theme', 'light') as ThemeMode)
|
|
41
|
+
const [selected, setSelected] = useState<string | null>(() => loadJSON<string | null>('windo:selected', null))
|
|
42
|
+
const [query, setQuery] = useState('')
|
|
43
|
+
const [navCollapsed, setNavCollapsed] = useState(() => loadJSON<boolean>('windo:nav-collapsed', false))
|
|
44
|
+
const [inspCollapsed, setInspCollapsed] = useState(() => loadJSON<boolean>('windo:insp-collapsed', false))
|
|
45
|
+
const inspectorPosition: InspectorPosition = 'right'
|
|
46
|
+
const accent = '#161618'
|
|
47
|
+
const [width, setWidth] = useState(() => loadJSON<number>('windo:width', 900))
|
|
48
|
+
const [height, setHeight] = useState<number | null>(() => loadJSON<number | null>('windo:height', null))
|
|
49
|
+
const [zoom, setZoom] = useState(() => loadJSON<number>('windo:zoom', 1))
|
|
50
|
+
const [grid, setGridState] = useState<CanvasGridOpts>(() => loadJSON<CanvasGridOpts>('windo:grid', CANVAS_GRID_DEFAULTS))
|
|
51
|
+
const [env, setEnv] = useState<ChromeEnv>(() => ({ ...loadJSON<ChromeEnv>('windo:env', DEFAULT_ENV), colorScheme: loadString('windo:theme', 'light') as ThemeMode }))
|
|
52
|
+
|
|
53
|
+
const [draftById, setDraftById] = useState<Record<string, string>>({})
|
|
54
|
+
// Last JSON pushed to the preview, per id. `dirty` = draft differs from this.
|
|
55
|
+
// Every push (seed, save, reset, variant, reload re-sync) updates it, so the
|
|
56
|
+
// flag tracks "the preview is stale vs the editor" rather than flipping on
|
|
57
|
+
// each keystroke.
|
|
58
|
+
const [savedById, setSavedById] = useState<Record<string, string>>({})
|
|
59
|
+
|
|
60
|
+
// Mirror the current draft in a ref so the reload re-sync can re-apply edited
|
|
61
|
+
// props without re-firing on every keystroke.
|
|
62
|
+
const draftRef = useRef(draftById)
|
|
63
|
+
draftRef.current = draftById
|
|
64
|
+
|
|
65
|
+
// Single push path: send props to the preview and record them as the saved
|
|
66
|
+
// baseline for the dirty check.
|
|
67
|
+
const pushProps = useCallback(
|
|
68
|
+
(id: string, json: string) => {
|
|
69
|
+
bridge.setProps(id, json)
|
|
70
|
+
setSavedById(prev => (prev[id] === json ? prev : { ...prev, [id]: json }))
|
|
71
|
+
},
|
|
72
|
+
[bridge.setProps]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ── persistence ─────────────────────────────────────────────────────────
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
document.documentElement.setAttribute('data-theme', theme)
|
|
78
|
+
saveString('windo:theme', theme)
|
|
79
|
+
}, [theme])
|
|
80
|
+
useEffect(() => saveJSON('windo:selected', selected), [selected])
|
|
81
|
+
useEffect(() => saveJSON('windo:nav-collapsed', navCollapsed), [navCollapsed])
|
|
82
|
+
useEffect(() => saveJSON('windo:insp-collapsed', inspCollapsed), [inspCollapsed])
|
|
83
|
+
useEffect(() => saveJSON('windo:width', width), [width])
|
|
84
|
+
useEffect(() => saveJSON('windo:height', height), [height])
|
|
85
|
+
useEffect(() => saveJSON('windo:zoom', zoom), [zoom])
|
|
86
|
+
useEffect(() => saveJSON('windo:grid', grid), [grid])
|
|
87
|
+
useEffect(() => saveJSON('windo:env', env), [env])
|
|
88
|
+
|
|
89
|
+
// ── selection wiring ───────────────────────────────────────────────────
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (bridge.manifest.length && selected === null) setSelected(bridge.manifest[0].id)
|
|
92
|
+
}, [bridge.manifest, selected])
|
|
93
|
+
|
|
94
|
+
// Send select on selection change AND whenever the iframe (re)signals ready —
|
|
95
|
+
// Vite reloads the preview iframe on dep-optimize and on every HMR update, so
|
|
96
|
+
// we must re-push the selection (and the edited props below) each time.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!selected) return
|
|
99
|
+
bridge.select(selected)
|
|
100
|
+
const json = draftRef.current[selected]
|
|
101
|
+
if (json !== undefined) pushProps(selected, json)
|
|
102
|
+
}, [selected, bridge.select, pushProps, bridge.readyNonce])
|
|
103
|
+
|
|
104
|
+
// Seed the Controls draft from the describe defaults the first time it arrives.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const d = bridge.describe
|
|
107
|
+
if (!d || d.id !== selected || !selected) return
|
|
108
|
+
if (draftById[selected] !== undefined) return
|
|
109
|
+
const json = JSON.stringify(d.defaults ?? {}, null, 2)
|
|
110
|
+
setDraftById(prev => ({ ...prev, [selected]: json }))
|
|
111
|
+
pushProps(selected, json)
|
|
112
|
+
}, [bridge.describe, selected, draftById, pushProps])
|
|
113
|
+
|
|
114
|
+
// ── env → preview ────────────────────────────────────────────────────────
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const next: WindoEnvState = {
|
|
117
|
+
colorScheme: theme,
|
|
118
|
+
viewport: { width, height: height ?? 0, name: viewportName(width) },
|
|
119
|
+
reducedMotion: env.reducedMotion,
|
|
120
|
+
direction: env.direction,
|
|
121
|
+
locale: env.locale,
|
|
122
|
+
contexts: env.contexts,
|
|
123
|
+
}
|
|
124
|
+
bridge.setEnv(next)
|
|
125
|
+
}, [env, width, height, theme, bridge.setEnv, bridge.readyNonce])
|
|
126
|
+
|
|
127
|
+
// ── draft commit / variant ─────────────────────────────────────────────
|
|
128
|
+
function setValuesJson(json: string) {
|
|
129
|
+
if (!selected) return
|
|
130
|
+
const id = selected
|
|
131
|
+
setDraftById(prev => ({ ...prev, [id]: json }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onSave() {
|
|
135
|
+
if (!selected) return
|
|
136
|
+
const json = draftById[selected]
|
|
137
|
+
if (json !== undefined) pushProps(selected, json)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onReset() {
|
|
141
|
+
if (!selected || !bridge.describe) return
|
|
142
|
+
const json = JSON.stringify(bridge.describe.defaults ?? {}, null, 2)
|
|
143
|
+
const id = selected
|
|
144
|
+
setDraftById(prev => ({ ...prev, [id]: json }))
|
|
145
|
+
pushProps(id, json)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function onApplyVariant(patch: Record<string, unknown>) {
|
|
149
|
+
if (!selected) return
|
|
150
|
+
let base: Record<string, unknown> = {}
|
|
151
|
+
const draft = draftById[selected]
|
|
152
|
+
try {
|
|
153
|
+
const parsed = draft === undefined ? bridge.describe?.defaults : JSON.parse(draft)
|
|
154
|
+
if (parsed && typeof parsed === 'object') base = parsed as Record<string, unknown>
|
|
155
|
+
} catch {
|
|
156
|
+
const fallback = bridge.describe?.defaults
|
|
157
|
+
if (fallback && typeof fallback === 'object') base = fallback as Record<string, unknown>
|
|
158
|
+
}
|
|
159
|
+
const json = JSON.stringify({ ...base, ...patch }, null, 2)
|
|
160
|
+
const id = selected
|
|
161
|
+
setDraftById(prev => ({ ...prev, [id]: json }))
|
|
162
|
+
pushProps(id, json)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── env editing ─────────────────────────────────────────────────────────
|
|
166
|
+
function setEnvPatch(patch: Partial<ChromeEnv>) {
|
|
167
|
+
setEnv(prev => ({ ...prev, ...patch }))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function setContextValue(name: string, key: string, value: unknown) {
|
|
171
|
+
setEnv(prev => ({
|
|
172
|
+
...prev,
|
|
173
|
+
contexts: {
|
|
174
|
+
...prev.contexts,
|
|
175
|
+
[name]: { ...(prev.contexts[name] ?? {}), [key]: value },
|
|
176
|
+
},
|
|
177
|
+
}))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function setGrid(patch: Partial<CanvasGridOpts> | null) {
|
|
181
|
+
if (patch === null) {
|
|
182
|
+
setGridState(CANVAS_GRID_DEFAULTS)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
setGridState(prev => ({ ...prev, ...patch }))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function toggleNav() {
|
|
189
|
+
setNavCollapsed(c => !c)
|
|
190
|
+
}
|
|
191
|
+
function toggleInsp() {
|
|
192
|
+
setInspCollapsed(c => !c)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── derived ─────────────────────────────────────────────────────────────
|
|
196
|
+
const entry = useMemo(() => bridge.manifest.find(e => e.id === selected) ?? null, [bridge.manifest, selected])
|
|
197
|
+
const clickActions = useMemo(() => (entry?.actions ?? []).filter(a => a.on === 'click'), [entry])
|
|
198
|
+
const actionDisabled = selected ? (bridge.actionDisabled[selected] ?? {}) : {}
|
|
199
|
+
const stateForEntry = selected ? (bridge.stateValues[selected] ?? {}) : {}
|
|
200
|
+
|
|
201
|
+
function onAction(actionId: string) {
|
|
202
|
+
if (!selected) return
|
|
203
|
+
bridge.invokeAction(selected, actionId)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const contextsForEntry = useMemo(() => {
|
|
207
|
+
const uses = entry?.uses ?? []
|
|
208
|
+
return bridge.contexts.filter(c => c.ambient || uses.includes(c.name))
|
|
209
|
+
}, [bridge.contexts, entry])
|
|
210
|
+
|
|
211
|
+
const valuesJson = (selected ? draftById[selected] : undefined) ?? ''
|
|
212
|
+
const savedJson = (selected ? savedById[selected] : undefined) ?? ''
|
|
213
|
+
const dirty = valuesJson !== savedJson
|
|
214
|
+
|
|
215
|
+
// ── accent variables (mirror app.jsx) ────────────────────────────────────
|
|
216
|
+
const accentUi = theme === 'dark' ? `color-mix(in oklab, ${accent} 35%, white)` : accent
|
|
217
|
+
const accentVars = {
|
|
218
|
+
'--accent': accent,
|
|
219
|
+
'--accent-ui': accentUi,
|
|
220
|
+
'--accent-soft': `color-mix(in srgb, ${accentUi}${theme === 'dark' ? ' 14%' : ' 8%'}, transparent)`,
|
|
221
|
+
} as CSSProperties
|
|
222
|
+
|
|
223
|
+
const title = bridge.title || 'windo'
|
|
224
|
+
const inspectorRight = inspectorPosition === 'right'
|
|
225
|
+
|
|
226
|
+
const canvasNode = (
|
|
227
|
+
<Canvas
|
|
228
|
+
iframeRef={iframeRef}
|
|
229
|
+
iframeSrc={IFRAME_SRC}
|
|
230
|
+
theme={theme}
|
|
231
|
+
width={width}
|
|
232
|
+
setWidth={setWidth}
|
|
233
|
+
height={height}
|
|
234
|
+
setHeight={setHeight}
|
|
235
|
+
zoom={zoom}
|
|
236
|
+
setZoom={setZoom}
|
|
237
|
+
grid={grid}
|
|
238
|
+
setGrid={setGrid}
|
|
239
|
+
actions={clickActions}
|
|
240
|
+
actionDisabled={actionDisabled}
|
|
241
|
+
onAction={onAction}
|
|
242
|
+
/>
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const inspectorNode = (
|
|
246
|
+
<Inspector
|
|
247
|
+
entry={entry}
|
|
248
|
+
describe={bridge.describe}
|
|
249
|
+
iframeSrc={IFRAME_SRC}
|
|
250
|
+
valuesJson={valuesJson}
|
|
251
|
+
setValuesJson={setValuesJson}
|
|
252
|
+
dirty={dirty}
|
|
253
|
+
onSave={onSave}
|
|
254
|
+
onReset={onReset}
|
|
255
|
+
parseErrors={bridge.parseErrors}
|
|
256
|
+
renderError={bridge.renderError}
|
|
257
|
+
logs={bridge.logs}
|
|
258
|
+
clearLogs={bridge.clearLogs}
|
|
259
|
+
state={stateForEntry}
|
|
260
|
+
contexts={contextsForEntry}
|
|
261
|
+
env={env}
|
|
262
|
+
setEnv={setEnvPatch}
|
|
263
|
+
setContextValue={setContextValue}
|
|
264
|
+
onApplyVariant={onApplyVariant}
|
|
265
|
+
position={inspectorPosition}
|
|
266
|
+
collapsed={inspCollapsed}
|
|
267
|
+
onToggle={toggleInsp}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div className="wb-app" style={accentVars} data-screen-label="Workbench">
|
|
273
|
+
<header className="wb-topbar">
|
|
274
|
+
<span className="wb-logo">
|
|
275
|
+
<span className="wb-logo-mark">{title.charAt(0).toUpperCase()}</span>
|
|
276
|
+
{title}
|
|
277
|
+
<span className="wb-logo-sub">/ Workbench</span>
|
|
278
|
+
</span>
|
|
279
|
+
{entry && (
|
|
280
|
+
<span className="wb-crumb">
|
|
281
|
+
<span>{entry.group}</span>
|
|
282
|
+
<span className="sep">›</span>
|
|
283
|
+
<b>{entry.title}</b>
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
<div className="wb-topbar-spacer" />
|
|
287
|
+
<button className="wb-iconbtn" type="button" title={theme === 'light' ? 'Switch to dark' : 'Switch to light'} onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
|
|
288
|
+
{theme === 'light' ? Icons.moon : Icons.sun}
|
|
289
|
+
</button>
|
|
290
|
+
</header>
|
|
291
|
+
|
|
292
|
+
<div className="wb-main">
|
|
293
|
+
<Sidebar groups={bridge.groups} manifest={bridge.manifest} selected={selected} onSelect={setSelected} query={query} setQuery={setQuery} collapsed={navCollapsed} onToggle={toggleNav} />
|
|
294
|
+
<div className="wb-center">
|
|
295
|
+
{inspectorRight ? (
|
|
296
|
+
<div className="wb-center-row">
|
|
297
|
+
{canvasNode}
|
|
298
|
+
{inspectorNode}
|
|
299
|
+
</div>
|
|
300
|
+
) : (
|
|
301
|
+
<>
|
|
302
|
+
{canvasNode}
|
|
303
|
+
{inspectorNode}
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Canvas — resizable surface (width + height), zoom, grid/background settings,
|
|
2
|
+
// and the selected windo's `click` actions (out-of-band triggers for toasts,
|
|
3
|
+
// modals, loading states). The render surface is a transparent <iframe>; the
|
|
4
|
+
// cosmetic grid + background are painted on the wrapper behind it so they show
|
|
5
|
+
// through.
|
|
6
|
+
|
|
7
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
|
|
8
|
+
import { useEffect, useRef, useState } from 'react'
|
|
9
|
+
import type { WindoActionMeta } from '../types'
|
|
10
|
+
import { Icons } from './icons'
|
|
11
|
+
import type { CanvasGridOpts, CanvasProps, ThemeMode } from './internal-types'
|
|
12
|
+
|
|
13
|
+
const PRESETS = [
|
|
14
|
+
{ id: 'mobile', label: 'Mobile', width: 375 },
|
|
15
|
+
{ id: 'tablet', label: 'Tablet', width: 768 },
|
|
16
|
+
{ id: 'desktop', label: 'Desktop', width: 1160 },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const MIN_WIDTH = 320
|
|
20
|
+
const MIN_HEIGHT = 280
|
|
21
|
+
const MAX_HEIGHT = 2400
|
|
22
|
+
|
|
23
|
+
function breakpointFor(w: number) {
|
|
24
|
+
if (w < 640) return 'mobile'
|
|
25
|
+
if (w < 1024) return 'tablet'
|
|
26
|
+
return 'desktop'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ---------- Canvas appearance settings ---------- */
|
|
30
|
+
|
|
31
|
+
function ColorField({ value, onPick, label }: { value: string; onPick: (c: string) => void; label: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<label className="wb-colorinput" title="Pick a color">
|
|
34
|
+
<span className="well" style={{ background: value }} />
|
|
35
|
+
<span className="hex">{value}</span>
|
|
36
|
+
<input type="color" value={value} aria-label={label} onChange={e => onPick(e.target.value)} />
|
|
37
|
+
</label>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function CanvasSettings({ grid, setGrid, theme, onClose }: { grid: CanvasGridOpts; setGrid: CanvasProps['setGrid']; theme: ThemeMode; onClose: () => void }) {
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
function onEsc(e: KeyboardEvent) {
|
|
44
|
+
if (e.key === 'Escape') onClose()
|
|
45
|
+
}
|
|
46
|
+
window.addEventListener('keydown', onEsc)
|
|
47
|
+
return () => window.removeEventListener('keydown', onEsc)
|
|
48
|
+
}, [onClose])
|
|
49
|
+
|
|
50
|
+
function modeRows(mode: ThemeMode) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<div className="wb-pop-sect">
|
|
54
|
+
{mode} mode{theme === mode && <span className="live">· active</span>}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="wb-pop-row">
|
|
57
|
+
<span className="lbl">Background</span>
|
|
58
|
+
<ColorField label={`${mode} background color`} value={grid[mode].bg} onPick={c => setGrid({ [mode]: { ...grid[mode], bg: c } })} />
|
|
59
|
+
</div>
|
|
60
|
+
<div className="wb-pop-row">
|
|
61
|
+
<span className="lbl">Grid color</span>
|
|
62
|
+
<ColorField label={`${mode} grid color`} value={grid[mode].dot} onPick={c => setGrid({ [mode]: { ...grid[mode], dot: c } })} />
|
|
63
|
+
</div>
|
|
64
|
+
</>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: click-away backdrop, not a keyboard control */}
|
|
71
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: click-away backdrop, not a keyboard control */}
|
|
72
|
+
<div className="wb-pop-overlay" onClick={onClose} />
|
|
73
|
+
<div className="wb-pop" role="dialog" aria-label="Canvas settings">
|
|
74
|
+
<div className="wb-pop-title">Canvas settings</div>
|
|
75
|
+
<div className="wb-pop-row">
|
|
76
|
+
<span className="lbl">Grid style</span>
|
|
77
|
+
<div className="wb-seg" style={{ flex: '0 0 auto' }}>
|
|
78
|
+
{(['dots', 'lines'] as const).map(s => (
|
|
79
|
+
<button type="button" key={s} className={grid.style === s && grid.on ? 'on' : ''} onClick={() => setGrid({ style: s, on: true })}>
|
|
80
|
+
{s}
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
<button type="button" className={grid.on ? '' : 'on'} onClick={() => setGrid({ on: false })}>
|
|
84
|
+
off
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="wb-pop-row">
|
|
89
|
+
<span className="lbl">Density</span>
|
|
90
|
+
<input type="range" min="12" max="44" step="2" value={grid.dotSize} onChange={e => setGrid({ dotSize: parseInt(e.target.value, 10) })} />
|
|
91
|
+
<span className="val">{grid.dotSize}px</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="wb-pop-row">
|
|
94
|
+
<span className="lbl">Opacity</span>
|
|
95
|
+
<input type="range" min="10" max="100" step="5" value={grid.gridOpacity} onChange={e => setGrid({ gridOpacity: parseInt(e.target.value, 10) })} />
|
|
96
|
+
<span className="val">{grid.gridOpacity}%</span>
|
|
97
|
+
</div>
|
|
98
|
+
{modeRows('light')}
|
|
99
|
+
{modeRows('dark')}
|
|
100
|
+
<div className="wb-pop-reset">
|
|
101
|
+
<button type="button" className="wb-reset" onClick={() => setGrid(null)}>
|
|
102
|
+
Reset canvas
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ---------- Resize handles ---------- */
|
|
111
|
+
|
|
112
|
+
function ResizeHandle({
|
|
113
|
+
side,
|
|
114
|
+
width,
|
|
115
|
+
setWidth,
|
|
116
|
+
maxWidth,
|
|
117
|
+
zoom,
|
|
118
|
+
onReset,
|
|
119
|
+
}: {
|
|
120
|
+
side: 'left' | 'right'
|
|
121
|
+
width: number
|
|
122
|
+
setWidth: (w: number) => void
|
|
123
|
+
maxWidth: number
|
|
124
|
+
zoom: number
|
|
125
|
+
onReset: () => void
|
|
126
|
+
}) {
|
|
127
|
+
const [dragging, setDragging] = useState(false)
|
|
128
|
+
|
|
129
|
+
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
const el = e.currentTarget
|
|
132
|
+
const pointerId = e.pointerId
|
|
133
|
+
el.setPointerCapture(pointerId)
|
|
134
|
+
setDragging(true)
|
|
135
|
+
const startX = e.clientX
|
|
136
|
+
const startW = width
|
|
137
|
+
|
|
138
|
+
function onMove(ev: PointerEvent) {
|
|
139
|
+
const delta = (ev.clientX - startX) / zoom
|
|
140
|
+
const signed = side === 'right' ? delta : -delta
|
|
141
|
+
const next = Math.max(MIN_WIDTH, Math.min(maxWidth, startW + signed * 2))
|
|
142
|
+
setWidth(next)
|
|
143
|
+
}
|
|
144
|
+
function onUp() {
|
|
145
|
+
try {
|
|
146
|
+
el.releasePointerCapture(pointerId)
|
|
147
|
+
} catch {
|
|
148
|
+
/* already released */
|
|
149
|
+
}
|
|
150
|
+
setDragging(false)
|
|
151
|
+
window.removeEventListener('pointermove', onMove)
|
|
152
|
+
window.removeEventListener('pointerup', onUp)
|
|
153
|
+
}
|
|
154
|
+
window.addEventListener('pointermove', onMove)
|
|
155
|
+
window.addEventListener('pointerup', onUp)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: drag handle is pointer-only by design
|
|
159
|
+
return <div className={`wb-handle${dragging ? ' dragging' : ''}`} title="Drag to resize width — double-click to fill" onDoubleClick={onReset} onPointerDown={onPointerDown} />
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function HeightHandle({ height, setHeight, zoom, onReset }: { height: number; setHeight: (h: number) => void; zoom: number; onReset: () => void }) {
|
|
163
|
+
const [dragging, setDragging] = useState(false)
|
|
164
|
+
|
|
165
|
+
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
const el = e.currentTarget
|
|
168
|
+
const pointerId = e.pointerId
|
|
169
|
+
el.setPointerCapture(pointerId)
|
|
170
|
+
setDragging(true)
|
|
171
|
+
const startY = e.clientY
|
|
172
|
+
const startH = height
|
|
173
|
+
|
|
174
|
+
function onMove(ev: PointerEvent) {
|
|
175
|
+
const delta = (ev.clientY - startY) / zoom
|
|
176
|
+
setHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startH + delta)))
|
|
177
|
+
}
|
|
178
|
+
function onUp() {
|
|
179
|
+
try {
|
|
180
|
+
el.releasePointerCapture(pointerId)
|
|
181
|
+
} catch {
|
|
182
|
+
/* already released */
|
|
183
|
+
}
|
|
184
|
+
setDragging(false)
|
|
185
|
+
window.removeEventListener('pointermove', onMove)
|
|
186
|
+
window.removeEventListener('pointerup', onUp)
|
|
187
|
+
}
|
|
188
|
+
window.addEventListener('pointermove', onMove)
|
|
189
|
+
window.addEventListener('pointerup', onUp)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: drag handle is pointer-only by design
|
|
193
|
+
return <div className={`wb-handle-h${dragging ? ' dragging' : ''}`} title="Drag to resize height — double-click to fit" onDoubleClick={onReset} onPointerDown={onPointerDown} />
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---------- Toolbar ---------- */
|
|
197
|
+
|
|
198
|
+
function CanvasToolbar({
|
|
199
|
+
width,
|
|
200
|
+
setWidth,
|
|
201
|
+
maxWidth,
|
|
202
|
+
zoom,
|
|
203
|
+
setZoom,
|
|
204
|
+
grid,
|
|
205
|
+
setGrid,
|
|
206
|
+
theme,
|
|
207
|
+
settingsOpen,
|
|
208
|
+
setSettingsOpen,
|
|
209
|
+
actions,
|
|
210
|
+
actionDisabled,
|
|
211
|
+
onAction,
|
|
212
|
+
}: {
|
|
213
|
+
width: number
|
|
214
|
+
setWidth: (w: number) => void
|
|
215
|
+
maxWidth: number
|
|
216
|
+
zoom: number
|
|
217
|
+
setZoom: (z: number) => void
|
|
218
|
+
grid: CanvasGridOpts
|
|
219
|
+
setGrid: CanvasProps['setGrid']
|
|
220
|
+
theme: ThemeMode
|
|
221
|
+
settingsOpen: boolean
|
|
222
|
+
setSettingsOpen: (open: boolean) => void
|
|
223
|
+
actions: WindoActionMeta[]
|
|
224
|
+
actionDisabled: Record<string, boolean>
|
|
225
|
+
onAction: (actionId: string) => void
|
|
226
|
+
}) {
|
|
227
|
+
return (
|
|
228
|
+
<div className="wb-toolbar">
|
|
229
|
+
{/* biome-ignore lint/a11y/useSemanticElements: toolbar grouping div; semantic element not warranted here */}
|
|
230
|
+
<div className="wb-seg" role="group" aria-label="Width presets">
|
|
231
|
+
{PRESETS.map(p => (
|
|
232
|
+
<button type="button" key={p.id} className={Math.abs(width - p.width) < 2 ? 'on' : ''} onClick={() => setWidth(Math.min(p.width, maxWidth))}>
|
|
233
|
+
{p.label}
|
|
234
|
+
</button>
|
|
235
|
+
))}
|
|
236
|
+
<button type="button" className={width >= maxWidth - 2 ? 'on' : ''} onClick={() => setWidth(maxWidth)}>
|
|
237
|
+
Fill
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
{actions.map(a => (
|
|
241
|
+
<button type="button" key={a.id} className="wb-action" disabled={actionDisabled[a.id] ?? false} onClick={() => onAction(a.id)}>
|
|
242
|
+
{a.label}
|
|
243
|
+
</button>
|
|
244
|
+
))}
|
|
245
|
+
<div className="gap" />
|
|
246
|
+
<button type="button" className={`wb-iconbtn${grid.on ? ' active' : ''}`} title={grid.on ? 'Hide grid' : 'Show grid'} onClick={() => setGrid({ on: !grid.on })}>
|
|
247
|
+
{Icons.grid}
|
|
248
|
+
</button>
|
|
249
|
+
<div className="wb-zoom">
|
|
250
|
+
<button type="button" className="wb-iconbtn" title="Zoom out" onClick={() => setZoom(Math.max(0.5, Math.round((zoom - 0.1) * 10) / 10))}>
|
|
251
|
+
{Icons.zoomOut}
|
|
252
|
+
</button>
|
|
253
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only zoom-reset affordance */}
|
|
254
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: pointer-only zoom-reset affordance */}
|
|
255
|
+
<span className="pct" title="Reset zoom" onClick={() => setZoom(1)}>
|
|
256
|
+
{Math.round(zoom * 100)}%
|
|
257
|
+
</span>
|
|
258
|
+
<button type="button" className="wb-iconbtn" title="Zoom in" onClick={() => setZoom(Math.min(1.5, Math.round((zoom + 0.1) * 10) / 10))}>
|
|
259
|
+
{Icons.zoomIn}
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
<button type="button" className={`wb-iconbtn${settingsOpen ? ' active' : ''}`} title="Canvas settings" onClick={() => setSettingsOpen(!settingsOpen)}>
|
|
263
|
+
{Icons.settings}
|
|
264
|
+
</button>
|
|
265
|
+
{settingsOpen && <CanvasSettings grid={grid} setGrid={setGrid} theme={theme} onClose={() => setSettingsOpen(false)} />}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ---------- Canvas ---------- */
|
|
271
|
+
|
|
272
|
+
export function Canvas(props: CanvasProps) {
|
|
273
|
+
const { iframeRef, iframeSrc, theme, width, setWidth, height, setHeight, zoom, setZoom, grid, setGrid, actions, actionDisabled, onAction } = props
|
|
274
|
+
|
|
275
|
+
const stageRef = useRef<HTMLDivElement>(null)
|
|
276
|
+
const [maxWidth, setMaxWidth] = useState(1160)
|
|
277
|
+
const [availH, setAvailH] = useState(620)
|
|
278
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const stage = stageRef.current
|
|
282
|
+
if (!stage) return
|
|
283
|
+
function measure() {
|
|
284
|
+
if (!stage) return
|
|
285
|
+
setMaxWidth(Math.max(360, stage.clientWidth - 92))
|
|
286
|
+
setAvailH(Math.max(320, stage.clientHeight - 76))
|
|
287
|
+
}
|
|
288
|
+
measure()
|
|
289
|
+
const ro = new ResizeObserver(measure)
|
|
290
|
+
ro.observe(stage)
|
|
291
|
+
return () => ro.disconnect()
|
|
292
|
+
}, [])
|
|
293
|
+
|
|
294
|
+
const clamped = Math.min(width, maxWidth)
|
|
295
|
+
const frameH = height ? Math.max(MIN_HEIGHT, height) : availH
|
|
296
|
+
const mode = theme === 'dark' ? grid.dark : grid.light
|
|
297
|
+
const bp = breakpointFor(clamped)
|
|
298
|
+
|
|
299
|
+
const gridClass = grid.on ? (grid.style === 'lines' ? ' grid-lines' : ' grid-dots') : ''
|
|
300
|
+
|
|
301
|
+
const frameStyle = {
|
|
302
|
+
width: clamped,
|
|
303
|
+
height: frameH,
|
|
304
|
+
'--cv-bg': mode.bg,
|
|
305
|
+
'--cv-dot': `color-mix(in srgb, ${mode.dot} ${grid.gridOpacity}%, transparent)`,
|
|
306
|
+
'--dot-size': `${grid.dotSize}px`,
|
|
307
|
+
} as CSSProperties
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div className="wb-canvas-col">
|
|
311
|
+
<CanvasToolbar
|
|
312
|
+
width={clamped}
|
|
313
|
+
setWidth={setWidth}
|
|
314
|
+
maxWidth={maxWidth}
|
|
315
|
+
zoom={zoom}
|
|
316
|
+
setZoom={setZoom}
|
|
317
|
+
grid={grid}
|
|
318
|
+
setGrid={setGrid}
|
|
319
|
+
theme={theme}
|
|
320
|
+
settingsOpen={settingsOpen}
|
|
321
|
+
setSettingsOpen={setSettingsOpen}
|
|
322
|
+
actions={actions}
|
|
323
|
+
actionDisabled={actionDisabled}
|
|
324
|
+
onAction={onAction}
|
|
325
|
+
/>
|
|
326
|
+
<div className="wb-stage" ref={stageRef}>
|
|
327
|
+
<div className="wb-zoomwrap" style={{ transform: `scale(${zoom})` }}>
|
|
328
|
+
<div className="wb-frame-col">
|
|
329
|
+
<div className="wb-frame-row">
|
|
330
|
+
<ResizeHandle side="left" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={() => setWidth(maxWidth)} />
|
|
331
|
+
<div className="wb-framewrap">
|
|
332
|
+
<span className="wb-corner tl" />
|
|
333
|
+
<span className="wb-corner tr" />
|
|
334
|
+
<span className="wb-corner bl" />
|
|
335
|
+
<span className="wb-corner br" />
|
|
336
|
+
<div className={`wb-frame${gridClass}`} style={frameStyle}>
|
|
337
|
+
<div className="wb-frame-inner">
|
|
338
|
+
<iframe
|
|
339
|
+
ref={iframeRef}
|
|
340
|
+
src={iframeSrc}
|
|
341
|
+
title="windo preview"
|
|
342
|
+
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', background: 'transparent', border: 0, display: 'block' }}
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<ResizeHandle side="right" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={() => setWidth(maxWidth)} />
|
|
348
|
+
</div>
|
|
349
|
+
<HeightHandle height={frameH} setHeight={setHeight} zoom={zoom} onReset={() => setHeight(null)} />
|
|
350
|
+
<div className="wb-dim-readout">
|
|
351
|
+
{Math.round(clamped)} × {Math.round(frameH)} px · <span className="bp">{bp}</span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
)
|
|
358
|
+
}
|