vite-plugin-nora 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/NOTICE +26 -0
- package/README.md +113 -0
- package/licenses/geist-OFL.txt +93 -0
- package/licenses/lucide-ISC.txt +17 -0
- package/package.json +90 -0
- package/src/cli.js +101 -0
- package/src/client/App.jsx +216 -0
- package/src/client/canvas/Canvas.jsx +95 -0
- package/src/client/canvas/ErrorBoundary.jsx +56 -0
- package/src/client/canvas/Preview.jsx +70 -0
- package/src/client/canvas/Viewport.jsx +211 -0
- package/src/client/chords.js +38 -0
- package/src/client/css.d.ts +5 -0
- package/src/client/fonts.css +48 -0
- package/src/client/frame.css +107 -0
- package/src/client/frame.html +11 -0
- package/src/client/frame.jsx +73 -0
- package/src/client/index.html +12 -0
- package/src/client/main.jsx +10 -0
- package/src/client/open-folder.js +84 -0
- package/src/client/selection.js +22 -0
- package/src/client/styles.css +1264 -0
- package/src/client/sweep/SweepPanel.jsx +206 -0
- package/src/client/sweep/measure.js +230 -0
- package/src/client/sweep/report.js +54 -0
- package/src/client/sweep/run-sweep.js +185 -0
- package/src/client/toolbar/Picker.jsx +343 -0
- package/src/client/toolbar/Toolbar.jsx +231 -0
- package/src/client/toolbar/ViewportMenu.jsx +83 -0
- package/src/client/toolbar/bar-shape.js +402 -0
- package/src/client/toolbar/icons.jsx +129 -0
- package/src/client/toolbar/use-draggable-bar.js +204 -0
- package/src/client/viewports.js +43 -0
- package/src/client/virtual.d.ts +28 -0
- package/src/index.js +4 -0
- package/src/server/create-server.js +147 -0
- package/src/server/plugin.js +207 -0
- package/src/server/safe-path.js +42 -0
- package/src/server/scan.js +350 -0
- package/types/index.d.ts +3 -0
- package/types/server/create-server.d.ts +22 -0
- package/types/server/plugin.d.ts +20 -0
- package/types/server/safe-path.d.ts +25 -0
- package/types/server/scan.d.ts +106 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { AlertIcon, ChevronUpIcon, FolderIcon } from "./icons.jsx";
|
|
3
|
+
import { openFolder } from "../open-folder.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One list for finding anything: the components in the folder you are pointed
|
|
7
|
+
* at, and the folders you could point at instead.
|
|
8
|
+
*
|
|
9
|
+
* This used to be two panels. You opened the folder browser, drilled to a
|
|
10
|
+
* directory, pressed a hover-only button to scan it, landed on an empty canvas,
|
|
11
|
+
* read a line of text telling you to press ⌘K, and opened a second panel to
|
|
12
|
+
* choose a component. Four of those five steps were the tool's bookkeeping
|
|
13
|
+
* rather than anything the user wanted, and the instruction only existed
|
|
14
|
+
* because picking a folder reloads the page and severed the thread.
|
|
15
|
+
*
|
|
16
|
+
* The collapse rests on one decision: **selecting a folder opens it**. There is
|
|
17
|
+
* no separate "browse into" and "choose this one" — descending into a folder
|
|
18
|
+
* and previewing it are the same act, so the two mechanics that contradicted
|
|
19
|
+
* each other become one. What you see afterwards is that folder's design, its
|
|
20
|
+
* components, and its subfolders, which is the same shape of list you were just
|
|
21
|
+
* looking at. Navigation is therefore closed under itself: every row leads to
|
|
22
|
+
* another list exactly like this one.
|
|
23
|
+
*
|
|
24
|
+
* Opening a folder still reloads, because the registry is a server-side virtual
|
|
25
|
+
* module and rebuilding it is the honest way to change what is scanned. The
|
|
26
|
+
* difference is that the reload now carries the picker with it (see App.jsx) and
|
|
27
|
+
* lands on the folder's own top-level design (see pickEntry in scan.js), so the
|
|
28
|
+
* reload is invisible and the dead end is gone.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Subsequence match with a light score: consecutive hits and matches right
|
|
33
|
+
* after a separator count for more, so "prc" finds PricingCard ahead of
|
|
34
|
+
* ProfileRadioControl.
|
|
35
|
+
*/
|
|
36
|
+
function score(query, target) {
|
|
37
|
+
if (!query) return 0;
|
|
38
|
+
const q = query.toLowerCase();
|
|
39
|
+
const t = target.toLowerCase();
|
|
40
|
+
let qi = 0;
|
|
41
|
+
let total = 0;
|
|
42
|
+
let streak = 0;
|
|
43
|
+
|
|
44
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
45
|
+
if (t[ti] !== q[qi]) {
|
|
46
|
+
streak = 0;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let point = 1 + streak;
|
|
50
|
+
const prev = t[ti - 1];
|
|
51
|
+
if (ti === 0 || prev === "/" || prev === "-" || prev === "." || prev === "_") point += 3;
|
|
52
|
+
else if (target[ti] === target[ti].toUpperCase() && target[ti] !== target[ti].toLowerCase())
|
|
53
|
+
point += 2;
|
|
54
|
+
total += point;
|
|
55
|
+
streak++;
|
|
56
|
+
qi++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return qi === q.length ? total : -1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Picker({ entries, currentDir, selectedId, onPick, onClose }) {
|
|
63
|
+
const [query, setQuery] = useState("");
|
|
64
|
+
const [cursor, setCursor] = useState(0);
|
|
65
|
+
const [dirs, setDirs] = useState(null);
|
|
66
|
+
const [error, setError] = useState(null);
|
|
67
|
+
const [pending, setPending] = useState(false);
|
|
68
|
+
const inputRef = useRef(null);
|
|
69
|
+
const listRef = useRef(null);
|
|
70
|
+
|
|
71
|
+
// `home` is the folder actually scanned; `browse` is the folder being looked
|
|
72
|
+
// at, which starts there and moves with the arrow keys. Keeping them apart is
|
|
73
|
+
// what lets you look three levels down without opening three folders on the
|
|
74
|
+
// way — only Enter commits, and only committing reloads.
|
|
75
|
+
const home = currentDir ?? "";
|
|
76
|
+
const [browse, setBrowse] = useState(home);
|
|
77
|
+
const parent = browse ? browse.split("/").slice(0, -1).join("/") : null;
|
|
78
|
+
const atHome = browse === home;
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
inputRef.current?.focus();
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
// Folder names and their component counts, straight from the server. Cheap:
|
|
85
|
+
// counting candidate files does not parse them, which is why the counts can
|
|
86
|
+
// be shown for folders that have never been scanned.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
fetch(`/__nora/dirs?path=${encodeURIComponent(browse)}`)
|
|
90
|
+
.then((r) => r.json())
|
|
91
|
+
.then((json) => {
|
|
92
|
+
if (cancelled) return;
|
|
93
|
+
// Settled once, rather than cleared up front. Clearing in the effect
|
|
94
|
+
// body is a synchronous setState during an effect, and a cascading
|
|
95
|
+
// render for a value that is almost always already null.
|
|
96
|
+
setError(json.error ?? null);
|
|
97
|
+
if (!json.error) setDirs(json.dirs);
|
|
98
|
+
})
|
|
99
|
+
.catch((err) => !cancelled && setError(String(err.message ?? err)));
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
};
|
|
103
|
+
}, [browse]);
|
|
104
|
+
|
|
105
|
+
const rows = useMemo(() => {
|
|
106
|
+
const q = query.trim();
|
|
107
|
+
|
|
108
|
+
const matched = !q
|
|
109
|
+
? entries
|
|
110
|
+
: entries
|
|
111
|
+
.map((e) => ({
|
|
112
|
+
e,
|
|
113
|
+
s: Math.max(score(q, e.name), score(q, `${e.group}/${e.name}`)),
|
|
114
|
+
}))
|
|
115
|
+
.filter((r) => r.s >= 0)
|
|
116
|
+
.sort((a, b) => b.s - a.s)
|
|
117
|
+
.map((r) => r.e);
|
|
118
|
+
|
|
119
|
+
// The top of the list is whatever you could land on from here. At the
|
|
120
|
+
// scanned folder that is its components; anywhere else it is the single act
|
|
121
|
+
// of opening the folder you have browsed to, since its components are not
|
|
122
|
+
// known until it is scanned.
|
|
123
|
+
const head = atHome
|
|
124
|
+
? matched.map((e) => ({ kind: "component", key: e.id, entry: e }))
|
|
125
|
+
: [{ kind: "open", key: "__open" }];
|
|
126
|
+
|
|
127
|
+
const folders = (dirs ?? [])
|
|
128
|
+
.filter((d) => !q || score(q, d.name) >= 0)
|
|
129
|
+
.map((d) => ({ kind: "folder", key: `dir:${d.name}`, dir: d }));
|
|
130
|
+
|
|
131
|
+
// Going up is navigation, not a result, so it is hidden while searching.
|
|
132
|
+
const up = parent !== null && !q ? [{ kind: "up", key: "__up" }] : [];
|
|
133
|
+
|
|
134
|
+
return [...head, ...up, ...folders];
|
|
135
|
+
}, [entries, dirs, query, parent, atHome]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const el = listRef.current?.querySelector('[data-active="true"]');
|
|
139
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
140
|
+
}, [cursor, rows]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Nothing clears `pending` on success, deliberately: `openFolder` replaces the
|
|
144
|
+
* document, so there is no later render to clear it in. A rejection is the
|
|
145
|
+
* only outcome this component survives.
|
|
146
|
+
*/
|
|
147
|
+
const open = async (dir) => {
|
|
148
|
+
setPending(true);
|
|
149
|
+
try {
|
|
150
|
+
await openFolder(dir);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
setError(String(err.message ?? err));
|
|
153
|
+
setPending(false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const childOf = (name) => (browse ? `${browse}/${name}` : name);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Move the list, and put the cursor back at the top.
|
|
161
|
+
*
|
|
162
|
+
* Both happen because of an event — a keystroke, a click — so they are done
|
|
163
|
+
* together here rather than by an effect watching `browse` afterwards. An
|
|
164
|
+
* effect would be React reacting to its own state change, one render late.
|
|
165
|
+
*/
|
|
166
|
+
const goTo = (path) => {
|
|
167
|
+
setBrowse(path);
|
|
168
|
+
setCursor(0);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Enter: land on this row. Only this commits, and only this reloads. */
|
|
172
|
+
const commit = (row) => {
|
|
173
|
+
if (!row || pending) return;
|
|
174
|
+
if (row.kind === "component") {
|
|
175
|
+
onPick(row.entry.id);
|
|
176
|
+
onClose();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (row.kind === "open") return open(browse);
|
|
180
|
+
if (row.kind === "up") return goTo(parent);
|
|
181
|
+
open(childOf(row.dir.name));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Right: look inside, without opening anything. */
|
|
185
|
+
const descend = (row) => {
|
|
186
|
+
if (!row || pending || row.kind !== "folder") return;
|
|
187
|
+
goTo(childOf(row.dir.name));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Everything the picker acts on while it is open. */
|
|
191
|
+
const CLAIMED = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter", "Escape"];
|
|
192
|
+
|
|
193
|
+
const onKeyDown = (e) => {
|
|
194
|
+
if (!CLAIMED.includes(e.key)) return; // ⌘K and friends pass through
|
|
195
|
+
|
|
196
|
+
// Claimed keys stop here. That is what lets the shell's listener stay
|
|
197
|
+
// simple: it no longer has to ask whether a panel is open before acting on
|
|
198
|
+
// an arrow. Letting them bubble is what once made a single ⌥↑ move this
|
|
199
|
+
// cursor *and* change the canvas underneath it.
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
|
|
203
|
+
if (e.key === "ArrowDown") setCursor((c) => Math.min(c + 1, rows.length - 1));
|
|
204
|
+
else if (e.key === "ArrowUp") setCursor((c) => Math.max(c - 1, 0));
|
|
205
|
+
else if (e.key === "Enter") commit(rows[cursor]);
|
|
206
|
+
else if (e.key === "ArrowRight") descend(rows[cursor]);
|
|
207
|
+
else if (e.key === "ArrowLeft") {
|
|
208
|
+
if (parent !== null) goTo(parent);
|
|
209
|
+
} else if (e.key === "Escape") onClose();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const componentCount = entries.length;
|
|
213
|
+
// The rule divides components from folders, so it only exists if there are
|
|
214
|
+
// components above it to divide. On a folder with none — a project root you
|
|
215
|
+
// have not pointed at anything yet — it would just be a stray line under the
|
|
216
|
+
// path.
|
|
217
|
+
const firstFolder = rows.findIndex((r) => r.kind !== "component");
|
|
218
|
+
const breakAt = firstFolder > 0 ? firstFolder : -1;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="nora-palette" role="dialog" aria-label="Find a Component">
|
|
222
|
+
<input
|
|
223
|
+
ref={inputRef}
|
|
224
|
+
className="nora-palette-input"
|
|
225
|
+
value={query}
|
|
226
|
+
placeholder={
|
|
227
|
+
componentCount
|
|
228
|
+
? `Search ${componentCount} Component${componentCount === 1 ? "" : "s"} and Folders…`
|
|
229
|
+
: "Search Folders…"
|
|
230
|
+
}
|
|
231
|
+
onChange={(e) => {
|
|
232
|
+
setQuery(e.target.value);
|
|
233
|
+
setCursor(0);
|
|
234
|
+
}}
|
|
235
|
+
onKeyDown={onKeyDown}
|
|
236
|
+
aria-label="Search Components and Folders"
|
|
237
|
+
disabled={pending}
|
|
238
|
+
/>
|
|
239
|
+
|
|
240
|
+
<div className="nora-picker-where">
|
|
241
|
+
<span className="nora-picker-path">{browse || "Project Root"}</span>
|
|
242
|
+
{pending ? <span className="nora-picker-pending">Opening…</span> : null}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{error ? <div className="nora-panel-error">{error}</div> : null}
|
|
246
|
+
|
|
247
|
+
<div className="nora-palette-list" ref={listRef}>
|
|
248
|
+
{rows.length === 0 ? (
|
|
249
|
+
<div className="nora-panel-empty">
|
|
250
|
+
{query.trim() ? `No match for “${query}”.` : "Nothing here."}
|
|
251
|
+
</div>
|
|
252
|
+
) : null}
|
|
253
|
+
|
|
254
|
+
{rows.map((row, i) => {
|
|
255
|
+
const active = i === cursor;
|
|
256
|
+
|
|
257
|
+
if (row.kind === "component") {
|
|
258
|
+
const entry = row.entry;
|
|
259
|
+
return (
|
|
260
|
+
<button
|
|
261
|
+
key={row.key}
|
|
262
|
+
data-active={active}
|
|
263
|
+
className={
|
|
264
|
+
"nora-palette-row" +
|
|
265
|
+
(active ? " is-active" : "") +
|
|
266
|
+
(entry.id === selectedId ? " is-selected" : "") +
|
|
267
|
+
(entry.unsupported ? " is-unsupported" : "")
|
|
268
|
+
}
|
|
269
|
+
onMouseEnter={() => setCursor(i)}
|
|
270
|
+
onClick={() => commit(row)}
|
|
271
|
+
title={entry.unsupported ?? entry.file}
|
|
272
|
+
>
|
|
273
|
+
<span className="nora-palette-name">{entry.name}</span>
|
|
274
|
+
<span className="nora-palette-path">
|
|
275
|
+
{entry.group ? `${entry.group}/` : ""}
|
|
276
|
+
{entry.file.split("/").pop()}
|
|
277
|
+
</span>
|
|
278
|
+
{entry.unsupported ? <AlertIcon size={13} /> : null}
|
|
279
|
+
</button>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (row.kind === "open") {
|
|
284
|
+
return (
|
|
285
|
+
<button
|
|
286
|
+
key={row.key}
|
|
287
|
+
data-active={active}
|
|
288
|
+
className={
|
|
289
|
+
"nora-palette-row nora-picker-dir nora-picker-open" + (active ? " is-active" : "")
|
|
290
|
+
}
|
|
291
|
+
onMouseEnter={() => setCursor(i)}
|
|
292
|
+
onClick={() => commit(row)}
|
|
293
|
+
disabled={pending}
|
|
294
|
+
title={`Open ${browse}`}
|
|
295
|
+
>
|
|
296
|
+
<FolderIcon size={14} />
|
|
297
|
+
<span className="nora-palette-name">Open {browse}</span>
|
|
298
|
+
</button>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const up = row.kind === "up";
|
|
303
|
+
return (
|
|
304
|
+
<div key={row.key} className={i === breakAt ? "nora-picker-break" : undefined}>
|
|
305
|
+
<button
|
|
306
|
+
data-active={active}
|
|
307
|
+
className={"nora-palette-row nora-picker-dir" + (active ? " is-active" : "")}
|
|
308
|
+
onMouseEnter={() => setCursor(i)}
|
|
309
|
+
onClick={() => commit(row)}
|
|
310
|
+
disabled={pending}
|
|
311
|
+
title={up ? `Open ${parent || "Project Root"}` : `Open ${row.dir.name}`}
|
|
312
|
+
>
|
|
313
|
+
{up ? <ChevronUpIcon size={14} /> : <FolderIcon size={14} />}
|
|
314
|
+
<span className="nora-palette-name">
|
|
315
|
+
{up ? parent || "Project Root" : row.dir.name}
|
|
316
|
+
</span>
|
|
317
|
+
<span className="nora-picker-count">{up ? "" : row.dir.count || ""}</span>
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
})}
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div className="nora-palette-foot">
|
|
325
|
+
<span>
|
|
326
|
+
<kbd>↑</kbd>
|
|
327
|
+
<kbd>↓</kbd>
|
|
328
|
+
<kbd>←</kbd>
|
|
329
|
+
<kbd>→</kbd> Move
|
|
330
|
+
</span>
|
|
331
|
+
<span>
|
|
332
|
+
<kbd>Enter</kbd> Choose
|
|
333
|
+
</span>
|
|
334
|
+
<span>
|
|
335
|
+
<kbd>Esc</kbd> Close
|
|
336
|
+
</span>
|
|
337
|
+
<span>
|
|
338
|
+
<kbd>⌘K</kbd> Open
|
|
339
|
+
</span>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Picker } from "./Picker.jsx";
|
|
3
|
+
import { ViewportMenu } from "./ViewportMenu.jsx";
|
|
4
|
+
import { SweepPanel } from "../sweep/SweepPanel.jsx";
|
|
5
|
+
import { GridIcon, SearchIcon, SunIcon, MoonIcon, SweepIcon, CollapseIcon } from "./icons.jsx";
|
|
6
|
+
import { useDraggableBar } from "./use-draggable-bar.js";
|
|
7
|
+
import { useBarShape } from "./bar-shape.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bar.
|
|
11
|
+
*
|
|
12
|
+
* Collapsed it is a circle; expanded it is three clusters joined by pinched
|
|
13
|
+
* bridges into a single silhouette — the component on its own, then the three
|
|
14
|
+
* ways of looking at it, then collapse.
|
|
15
|
+
*
|
|
16
|
+
* The grouping is not decoration. Buttons inside one cluster are read as one
|
|
17
|
+
* subject, so what shares a pill has to belong together, and the split here is
|
|
18
|
+
* between *what* you are looking at and *how*. The name leads alone because it
|
|
19
|
+
* is the only control that changes the subject: everything else on the bar is
|
|
20
|
+
* downstream of whatever it names. Width, sweep and theme then group, because
|
|
21
|
+
* each is a way of inspecting that one subject — how wide, how it holds up
|
|
22
|
+
* across widths, under which lighting — and none of them changes what it is.
|
|
23
|
+
*
|
|
24
|
+
* Collapse gets the tail on its own because it is not about the canvas at all;
|
|
25
|
+
* it dismisses the bar. That also puts it at the edge the shut circle pins
|
|
26
|
+
* itself to, so the bar closes toward the button that closed it — see the
|
|
27
|
+
* anchor note in use-draggable-bar.js.
|
|
28
|
+
*
|
|
29
|
+
* The silhouette itself is drawn by `useBarShape` as a single path behind the
|
|
30
|
+
* controls; see bar-shape.js for why it has to be one path rather than three
|
|
31
|
+
* elements. Everything structural about this component — the `.nora-pill`
|
|
32
|
+
* element, its ref, its pointer handler, its `data-state` — is unchanged by
|
|
33
|
+
* that, which is what lets dragging, panel anchoring and the sweep stay out of
|
|
34
|
+
* it entirely.
|
|
35
|
+
*/
|
|
36
|
+
export function Toolbar({
|
|
37
|
+
expanded,
|
|
38
|
+
setExpanded,
|
|
39
|
+
panel,
|
|
40
|
+
setPanel,
|
|
41
|
+
entries,
|
|
42
|
+
currentDir,
|
|
43
|
+
selectedId,
|
|
44
|
+
onSelect,
|
|
45
|
+
theme,
|
|
46
|
+
onToggleTheme,
|
|
47
|
+
viewport,
|
|
48
|
+
scale,
|
|
49
|
+
onPickViewport,
|
|
50
|
+
onSweep,
|
|
51
|
+
onToggleSweep,
|
|
52
|
+
sweeping,
|
|
53
|
+
sweepResult,
|
|
54
|
+
onPickWidth,
|
|
55
|
+
}) {
|
|
56
|
+
const ref = useRef(null);
|
|
57
|
+
const pillRef = useRef(null);
|
|
58
|
+
const controlsRef = useRef(null);
|
|
59
|
+
const selected = entries.find((e) => e.id === selectedId);
|
|
60
|
+
const { pos, style, dragging, onPointerDown } = useDraggableBar(pillRef, expanded);
|
|
61
|
+
|
|
62
|
+
const label = selected ? selected.name : currentDir ? "Choose a Component" : "No Folder";
|
|
63
|
+
const scaled = viewport.width && scale < 0.999 ? Math.round(scale * 100) : null;
|
|
64
|
+
|
|
65
|
+
// Anything that can move a cluster edge and so change the outline.
|
|
66
|
+
const { svgRef, pathRef } = useBarShape(
|
|
67
|
+
pillRef,
|
|
68
|
+
controlsRef,
|
|
69
|
+
expanded,
|
|
70
|
+
`${label}|${viewport.label}|${scaled}`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Close an open panel when a click lands outside the bar entirely.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!panel) return;
|
|
76
|
+
const onDown = (e) => {
|
|
77
|
+
if (ref.current && !ref.current.contains(e.target)) setPanel(null);
|
|
78
|
+
};
|
|
79
|
+
document.addEventListener("mousedown", onDown);
|
|
80
|
+
return () => document.removeEventListener("mousedown", onDown);
|
|
81
|
+
}, [panel, setPanel]);
|
|
82
|
+
|
|
83
|
+
const toggle = (name) => setPanel(panel === name ? null : name);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className="nora-bar-area"
|
|
88
|
+
ref={ref}
|
|
89
|
+
style={style}
|
|
90
|
+
data-hside={pos.hside}
|
|
91
|
+
data-vside={pos.vside}
|
|
92
|
+
data-dragging={dragging ? "true" : "false"}
|
|
93
|
+
>
|
|
94
|
+
{panel === "picker" ? (
|
|
95
|
+
<Picker
|
|
96
|
+
entries={entries}
|
|
97
|
+
currentDir={currentDir}
|
|
98
|
+
selectedId={selectedId}
|
|
99
|
+
onPick={onSelect}
|
|
100
|
+
onClose={() => setPanel(null)}
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
103
|
+
|
|
104
|
+
{panel === "sweep" && sweepResult ? (
|
|
105
|
+
<SweepPanel
|
|
106
|
+
result={sweepResult}
|
|
107
|
+
subject={selected}
|
|
108
|
+
currentWidth={viewport.width}
|
|
109
|
+
onPickWidth={onPickWidth}
|
|
110
|
+
onRerun={onSweep}
|
|
111
|
+
onClose={() => setPanel(null)}
|
|
112
|
+
sweeping={sweeping}
|
|
113
|
+
/>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
{panel === "viewport" ? (
|
|
117
|
+
<ViewportMenu
|
|
118
|
+
current={viewport.id}
|
|
119
|
+
onPick={onPickViewport}
|
|
120
|
+
onClose={() => setPanel(null)}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
|
|
124
|
+
<div
|
|
125
|
+
className="nora-pill"
|
|
126
|
+
ref={pillRef}
|
|
127
|
+
onPointerDown={onPointerDown}
|
|
128
|
+
data-state={expanded ? "expanded" : "collapsed"}
|
|
129
|
+
role={expanded ? "toolbar" : "button"}
|
|
130
|
+
tabIndex={expanded ? -1 : 0}
|
|
131
|
+
aria-label="nora Toolbar"
|
|
132
|
+
aria-expanded={expanded}
|
|
133
|
+
onClick={(e) => {
|
|
134
|
+
if (expanded) return;
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
setExpanded(true);
|
|
137
|
+
}}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (!expanded && (e.key === "Enter" || e.key === " ")) {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
setExpanded(true);
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{/* The silhouette. Painted, never composed — see bar-shape.js. */}
|
|
146
|
+
<svg className="nora-shape" ref={svgRef} aria-hidden="true" focusable="false">
|
|
147
|
+
<path ref={pathRef} />
|
|
148
|
+
</svg>
|
|
149
|
+
|
|
150
|
+
<span className={"nora-pill-face" + (expanded ? " is-out" : "")}>
|
|
151
|
+
<GridIcon size={18} strokeWidth={2} />
|
|
152
|
+
</span>
|
|
153
|
+
|
|
154
|
+
<span
|
|
155
|
+
className={"nora-pill-controls" + (expanded ? " is-in" : " is-out")}
|
|
156
|
+
ref={controlsRef}
|
|
157
|
+
>
|
|
158
|
+
<span className="nora-cluster nora-cluster-subject">
|
|
159
|
+
<button
|
|
160
|
+
className={"nora-name-btn" + (panel === "picker" ? " is-active" : "")}
|
|
161
|
+
onClick={() => toggle("picker")}
|
|
162
|
+
title={selected ? selected.file : "Find a Component (⌘K)"}
|
|
163
|
+
tabIndex={expanded ? 0 : -1}
|
|
164
|
+
>
|
|
165
|
+
<SearchIcon size={14} strokeWidth={1.9} className="nora-name-search" />
|
|
166
|
+
<span className="nora-name-label">{label}</span>
|
|
167
|
+
</button>
|
|
168
|
+
</span>
|
|
169
|
+
|
|
170
|
+
<span className="nora-bridge" aria-hidden="true" />
|
|
171
|
+
|
|
172
|
+
<span className="nora-cluster">
|
|
173
|
+
<button
|
|
174
|
+
className={"nora-vp-btn" + (panel === "viewport" ? " is-active" : "")}
|
|
175
|
+
onClick={() => toggle("viewport")}
|
|
176
|
+
disabled={!selectedId}
|
|
177
|
+
aria-label="Viewport Width"
|
|
178
|
+
title="Viewport Width"
|
|
179
|
+
tabIndex={expanded ? 0 : -1}
|
|
180
|
+
>
|
|
181
|
+
{viewport.label}
|
|
182
|
+
{scaled ? <span className="nora-vp-scale">{scaled}%</span> : null}
|
|
183
|
+
</button>
|
|
184
|
+
|
|
185
|
+
<button
|
|
186
|
+
className={
|
|
187
|
+
"nora-icon-btn nora-sweep-btn" +
|
|
188
|
+
(sweeping ? " is-running" : "") +
|
|
189
|
+
(panel === "sweep" ? " is-active" : "")
|
|
190
|
+
}
|
|
191
|
+
onClick={onToggleSweep}
|
|
192
|
+
disabled={!selectedId || sweeping}
|
|
193
|
+
aria-label="Sweep Widths for Layout Problems"
|
|
194
|
+
title={sweepResult ? "Sweep Results" : "Sweep 320–1600 for Overflow and Breakpoints"}
|
|
195
|
+
tabIndex={expanded ? 0 : -1}
|
|
196
|
+
>
|
|
197
|
+
<SweepIcon />
|
|
198
|
+
</button>
|
|
199
|
+
|
|
200
|
+
<button
|
|
201
|
+
className="nora-icon-btn"
|
|
202
|
+
onClick={onToggleTheme}
|
|
203
|
+
aria-label="Toggle Canvas Theme"
|
|
204
|
+
title="Toggle Canvas Theme"
|
|
205
|
+
tabIndex={expanded ? 0 : -1}
|
|
206
|
+
>
|
|
207
|
+
{theme === "dark" ? <MoonIcon /> : <SunIcon />}
|
|
208
|
+
</button>
|
|
209
|
+
</span>
|
|
210
|
+
|
|
211
|
+
<span className="nora-bridge" aria-hidden="true" />
|
|
212
|
+
|
|
213
|
+
<span className="nora-cluster">
|
|
214
|
+
<button
|
|
215
|
+
className="nora-icon-btn"
|
|
216
|
+
onClick={() => {
|
|
217
|
+
setPanel(null);
|
|
218
|
+
setExpanded(false);
|
|
219
|
+
}}
|
|
220
|
+
aria-label="Collapse Toolbar"
|
|
221
|
+
title="Collapse"
|
|
222
|
+
tabIndex={expanded ? 0 : -1}
|
|
223
|
+
>
|
|
224
|
+
<CollapseIcon size={16} />
|
|
225
|
+
</button>
|
|
226
|
+
</span>
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { VIEWPORTS } from "../viewports.js";
|
|
3
|
+
|
|
4
|
+
/** Everything the menu acts on while it is open. */
|
|
5
|
+
const CLAIMED = ["ArrowDown", "ArrowUp", "Enter", "Escape"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pick a viewport width.
|
|
9
|
+
*
|
|
10
|
+
* Driveable from the keyboard on the same terms as the picker: the arrows move
|
|
11
|
+
* a cursor, Enter takes what it is on, Escape leaves. It has no text field to
|
|
12
|
+
* hold focus, so the panel itself takes it — the cursor highlight is the
|
|
13
|
+
* affordance, which is why the panel's own focus ring is suppressed.
|
|
14
|
+
*
|
|
15
|
+
* Claimed keys stop here rather than bubbling, so the shell's window listener
|
|
16
|
+
* never has to ask whether a panel is open before acting on an arrow.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} props
|
|
19
|
+
* @param {string} props.current id of the viewport in use
|
|
20
|
+
* @param {(id: string) => void} props.onPick
|
|
21
|
+
* @param {() => void} props.onClose
|
|
22
|
+
*/
|
|
23
|
+
export function ViewportMenu({ current, onPick, onClose }) {
|
|
24
|
+
const panelRef = useRef(null);
|
|
25
|
+
const [cursor, setCursor] = useState(() => {
|
|
26
|
+
const at = VIEWPORTS.findIndex((v) => v.id === current);
|
|
27
|
+
return at < 0 ? 0 : at;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Opens on the width you are already using, so Enter is a no-op rather than
|
|
31
|
+
// a surprise.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
panelRef.current?.focus();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const pick = (v) => {
|
|
37
|
+
onPick(v.id);
|
|
38
|
+
onClose();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const onKeyDown = (e) => {
|
|
42
|
+
if (!CLAIMED.includes(e.key)) return; // ⌘K and friends pass through
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
|
|
46
|
+
if (e.key === "ArrowDown") setCursor((c) => Math.min(c + 1, VIEWPORTS.length - 1));
|
|
47
|
+
else if (e.key === "ArrowUp") setCursor((c) => Math.max(c - 1, 0));
|
|
48
|
+
else if (e.key === "Enter") pick(VIEWPORTS[cursor]);
|
|
49
|
+
else if (e.key === "Escape") onClose();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className="nora-panel nora-viewport-menu"
|
|
55
|
+
role="dialog"
|
|
56
|
+
aria-label="Viewport Width"
|
|
57
|
+
ref={panelRef}
|
|
58
|
+
tabIndex={-1}
|
|
59
|
+
onKeyDown={onKeyDown}
|
|
60
|
+
>
|
|
61
|
+
<div className="nora-panel-head">
|
|
62
|
+
<span className="nora-panel-title">Viewport</span>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="nora-panel-body">
|
|
66
|
+
{VIEWPORTS.map((v, i) => (
|
|
67
|
+
<button
|
|
68
|
+
key={v.id}
|
|
69
|
+
data-active={i === cursor}
|
|
70
|
+
className={
|
|
71
|
+
"nora-row" + (v.id === current ? " is-current" : "") + (i === cursor ? " is-at" : "")
|
|
72
|
+
}
|
|
73
|
+
onMouseEnter={() => setCursor(i)}
|
|
74
|
+
onClick={() => pick(v)}
|
|
75
|
+
>
|
|
76
|
+
<span className="nora-row-name">{v.label}</span>
|
|
77
|
+
<span className="nora-row-count">{v.note}</span>
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|