@protoboardai/views 0.0.0-stage → 0.2.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.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +46 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Pan and zoom for an SVG drawing by its viewBox (the drawing stays vector
3
+ * sharp at any zoom), with the canvas's own interactions (the diagram's,
4
+ * uhd-viewer canvas/interaction.ts):
5
+ *
6
+ * - two tools, Select (V, the default) and Pan (H): with Select a left-drag
7
+ * draws a selection box, with Pan it moves the view; Space held makes a
8
+ * left-drag pan with either; the middle button always pans;
9
+ * - the wheel (and a pinch: a trackpad's, or two fingers) zooms about the
10
+ * pointer;
11
+ * - F fits, + and - zoom about the centre, 0 goes back to the fit's zoom;
12
+ * - the drawing is fitted when it first has a size and a box, and fitted
13
+ * again on a resize until the person moves the view.
14
+ *
15
+ * The view lives in a ref and is written to the `<svg>`'s viewBox directly:
16
+ * a pan never re-renders the drawing. React hears of it once a frame
17
+ * (`view`), for what is sized to the zoom (markers, the selection box).
18
+ */
19
+ import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
20
+ import { canvasCommand, initialInteraction, interactionReducer, type CanvasTool } from "@deltarobotics/uhd-viewer/components";
21
+
22
+ export type Box = [number, number, number, number];
23
+
24
+ export interface View {
25
+ /** Drawing units at the centre of the host, and screen pixels per unit. */
26
+ cx: number;
27
+ cy: number;
28
+ s: number;
29
+ }
30
+
31
+ /** Room a fit leaves clear at each side of the host (floating panels), in pixels. */
32
+ export interface Insets {
33
+ left: number;
34
+ right: number;
35
+ top: number;
36
+ bottom: number;
37
+ }
38
+
39
+ export interface PanZoomOptions {
40
+ /** What a fit shows (drawing units); the drawing's own box until the content's is known. */
41
+ box: Box | undefined;
42
+ /** Room to keep clear when fitting (asked at each fit: panels open and close). */
43
+ insets?: () => Partial<Insets>;
44
+ /** A selection box drawn with the Select tool (drawing units), and whether Shift added to the selection. */
45
+ onBox?: (box: Box, additive: boolean) => void;
46
+ /** Escape pressed on the view. */
47
+ onEscape?: () => void;
48
+ }
49
+
50
+ export interface PanZoom {
51
+ /** The host element's ref: the element the pointer and the wheel act on. */
52
+ ref: (el: HTMLElement | null) => void;
53
+ /** The current view (updated at most once a frame). */
54
+ view: View | null;
55
+ fit(): void;
56
+ zoomBy(k: number): void;
57
+ /** Bring a drawing point to the centre, zoomed in to at least `zoom` times the fit (a finding, a component). */
58
+ centerOn(x: number, y: number, zoom?: number): void;
59
+ /** Screen point (client coordinates) → drawing units. */
60
+ toDrawing(clientX: number, clientY: number): [number, number] | undefined;
61
+ /** The pointer moved more than a click since it went down (the click that ends a drag is not a pick). */
62
+ dragged: () => boolean;
63
+ tool: CanvasTool;
64
+ setTool(tool: CanvasTool): void;
65
+ /** Space is held, or the Pan tool is chosen: a left-drag pans. */
66
+ panning: boolean;
67
+ /** The selection box being drawn, in the host's pixels (left, top, width, height). */
68
+ selecting: Box | null;
69
+ }
70
+
71
+ const MIN_ZOOM = 0.2;
72
+ const MAX_ZOOM = 200;
73
+ const CLICK_SLOP = 4;
74
+ const FIT_MARGIN = 0.96;
75
+
76
+ const insetsOf = (o: PanZoomOptions): Insets => ({ left: 0, right: 0, top: 0, bottom: 0, ...(o.insets?.() ?? {}) });
77
+
78
+ /** The scale and centre that show `box` inside `w` × `h` less `insets`. */
79
+ export function fitView(box: Box, w: number, h: number, insets: Insets): View {
80
+ // never fit into less than a third of the host: a small window keeps the drawing under its panels instead
81
+ const iw = Math.max(w / 3, w - insets.left - insets.right);
82
+ const ih = Math.max(h / 3, h - insets.top - insets.bottom);
83
+ const dx = iw === w / 3 ? 0 : (insets.left - insets.right) / 2;
84
+ const dy = ih === h / 3 ? 0 : (insets.top - insets.bottom) / 2;
85
+ const s = Math.min(iw / box[2], ih / box[3]) * FIT_MARGIN;
86
+ return { s, cx: box[0] + box[2] / 2 - dx / s, cy: box[1] + box[3] / 2 - dy / s };
87
+ }
88
+
89
+ /** The view after zooming by `k` about the host point (px, py) (the point under it stays put). */
90
+ export function zoomAbout(v: View, k: number, px: number, py: number, w: number, h: number, limits: [number, number]): View {
91
+ const s = Math.min(limits[1], Math.max(limits[0], v.s * k));
92
+ const ox = px - w / 2;
93
+ const oy = py - h / 2;
94
+ const ux = v.cx + ox / v.s;
95
+ const uy = v.cy + oy / v.s;
96
+ return { s, cx: ux - ox / s, cy: uy - oy / s };
97
+ }
98
+
99
+ /** The viewBox attribute that shows `v` in a `w` × `h` host. */
100
+ export const viewBoxOf = (v: View, w: number, h: number) => `${v.cx - w / 2 / v.s} ${v.cy - h / 2 / v.s} ${w / v.s} ${h / v.s}`;
101
+
102
+ export function usePanZoom(svg: () => SVGSVGElement | null, options: PanZoomOptions): PanZoom {
103
+ const [el, setEl] = useState<HTMLElement | null>(null);
104
+ const opts = useRef(options);
105
+ opts.current = options;
106
+ // the getter is a new function each render: read through a ref, so the listeners (and a drag under way) stay
107
+ const svgRef = useRef(svg);
108
+ svgRef.current = svg;
109
+ const box = options.box;
110
+ const boxKey = box?.map((n) => n.toFixed(4)).join(" ");
111
+
112
+ const viewRef = useRef<View | null>(null);
113
+ const [view, setView] = useState<View | null>(null);
114
+ const fitScale = useRef(1);
115
+ /** The person moved the view since the last automatic fit. */
116
+ const moved = useRef(false);
117
+ const dragMoved = useRef(false);
118
+ const [interaction, dispatch] = useReducer(interactionReducer, initialInteraction);
119
+ const panning = interaction.tool === "pan" || interaction.space;
120
+ const panningRef = useRef(panning);
121
+ panningRef.current = panning;
122
+ const [selecting, setSelecting] = useState<Box | null>(null);
123
+
124
+ const size = useCallback(() => (el ? { w: Math.max(1, el.clientWidth), h: Math.max(1, el.clientHeight) } : { w: 1, h: 1 }), [el]);
125
+
126
+ // the view written to the drawing: now, and React told once a frame
127
+ const frame = useRef(0);
128
+ const apply = useCallback(() => {
129
+ const s = svgRef.current();
130
+ const v = viewRef.current;
131
+ if (!s || !v || !el) return;
132
+ const { w, h } = size();
133
+ s.setAttribute("viewBox", viewBoxOf(v, w, h));
134
+ s.setAttribute("width", String(w));
135
+ s.setAttribute("height", String(h));
136
+ s.setAttribute("preserveAspectRatio", "xMidYMid meet");
137
+ // a level of detail for the drawing's own rules (labels that only read when zoomed in)
138
+ s.style.setProperty("--pb-px", String(1 / v.s));
139
+ }, [el, size]);
140
+ const commit = useCallback(
141
+ (v: View | null, byPerson: boolean) => {
142
+ viewRef.current = v;
143
+ if (byPerson) moved.current = true;
144
+ apply();
145
+ if (!frame.current)
146
+ frame.current = requestAnimationFrame(() => {
147
+ frame.current = 0;
148
+ setView(viewRef.current);
149
+ });
150
+ },
151
+ [apply],
152
+ );
153
+ useEffect(() => () => cancelAnimationFrame(frame.current), []);
154
+ // a new <svg> (a drawing loaded, a side flipped) gets the view at once
155
+ useLayoutEffect(() => apply());
156
+
157
+ const limits = (): [number, number] => [fitScale.current * MIN_ZOOM, fitScale.current * MAX_ZOOM];
158
+
159
+ const fit = useCallback(() => {
160
+ const b = opts.current.box;
161
+ if (!b || !el) return;
162
+ const { w, h } = size();
163
+ if (w < 8 || h < 8) return;
164
+ const v = fitView(b, w, h, insetsOf(opts.current));
165
+ fitScale.current = v.s;
166
+ moved.current = false;
167
+ commit(v, false);
168
+ }, [el, size, commit]);
169
+
170
+ // fitted when the drawing first has a box and the host a size, and again when the box changes
171
+ const fittedKey = useRef<string | undefined>(undefined);
172
+ useLayoutEffect(() => {
173
+ if (!el || !boxKey || fittedKey.current === boxKey) return;
174
+ const { w, h } = size();
175
+ if (w < 8 || h < 8) return;
176
+ fittedKey.current = boxKey;
177
+ fit();
178
+ }, [el, boxKey, fit, size]);
179
+
180
+ // a resize: fitted again until the person moved the view; after that the centre and the zoom relative to fit stay
181
+ useEffect(() => {
182
+ if (!el) return;
183
+ let last = size();
184
+ const ro = new ResizeObserver(() => {
185
+ const next = size();
186
+ if (next.w === last.w && next.h === last.h) return;
187
+ last = next;
188
+ const b = opts.current.box;
189
+ if (!b) return;
190
+ if (fittedKey.current !== boxKey || !moved.current || !viewRef.current) {
191
+ fittedKey.current = boxKey;
192
+ return fit();
193
+ }
194
+ const k = fitView(b, next.w, next.h, insetsOf(opts.current)).s;
195
+ const ratio = k / fitScale.current;
196
+ fitScale.current = k;
197
+ const v = viewRef.current;
198
+ commit({ ...v, s: v.s * ratio }, false);
199
+ });
200
+ ro.observe(el);
201
+ return () => ro.disconnect();
202
+ }, [el, boxKey, fit, size, commit]);
203
+
204
+ const zoomAt = useCallback(
205
+ (k: number, px?: number, py?: number) => {
206
+ const v = viewRef.current;
207
+ if (!v) return;
208
+ const { w, h } = size();
209
+ commit(zoomAbout(v, k, px ?? w / 2, py ?? h / 2, w, h, limits()), true);
210
+ },
211
+ [size, commit],
212
+ );
213
+
214
+ // the pointer, the wheel and touch on the host
215
+ useEffect(() => {
216
+ if (!el) return;
217
+ const local = (e: { clientX: number; clientY: number }) => {
218
+ const r = el.getBoundingClientRect();
219
+ return [e.clientX - r.left, e.clientY - r.top] as const;
220
+ };
221
+ const onWheel = (e: WheelEvent) => {
222
+ e.preventDefault();
223
+ const [x, y] = local(e);
224
+ const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
225
+ // a pinch on a trackpad arrives as a wheel with Ctrl: it zooms faster
226
+ zoomAt(Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0015)), x, y);
227
+ };
228
+ type Gesture = { mode: "pan" | "box"; id: number; x: number; y: number; start: View; additive: boolean };
229
+ let g: Gesture | null = null;
230
+ const touches = new Map<number, [number, number]>();
231
+ let pinch: { d: number; s: number } | null = null;
232
+ const down = (e: PointerEvent) => {
233
+ if (e.pointerType === "touch") {
234
+ touches.set(e.pointerId, [e.clientX, e.clientY]);
235
+ if (touches.size === 2) {
236
+ const [a, b] = [...touches.values()];
237
+ pinch = { d: Math.hypot(a[0] - b[0], a[1] - b[1]), s: viewRef.current?.s ?? 1 };
238
+ g = null;
239
+ return;
240
+ }
241
+ }
242
+ if (e.button !== 0 && e.button !== 1) return;
243
+ const v = viewRef.current;
244
+ if (!v) return;
245
+ // the middle button pans with either tool; the browser's own autoscroll never starts
246
+ if (e.button === 1) e.preventDefault();
247
+ dragMoved.current = false;
248
+ const pan = e.button === 1 || panningRef.current || e.pointerType === "touch" || !opts.current.onBox;
249
+ g = { mode: pan ? "pan" : "box", id: e.pointerId, x: e.clientX, y: e.clientY, start: v, additive: e.shiftKey || e.ctrlKey || e.metaKey };
250
+ };
251
+ const move = (e: PointerEvent) => {
252
+ if (pinch && touches.has(e.pointerId)) {
253
+ touches.set(e.pointerId, [e.clientX, e.clientY]);
254
+ const [a, b] = [...touches.values()];
255
+ const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
256
+ const v = viewRef.current;
257
+ if (v && pinch.d > 0) {
258
+ const [x, y] = local({ clientX: (a[0] + b[0]) / 2, clientY: (a[1] + b[1]) / 2 });
259
+ zoomAt((pinch.s * (d / pinch.d)) / v.s, x, y);
260
+ }
261
+ dragMoved.current = true;
262
+ return;
263
+ }
264
+ if (!g || e.pointerId !== g.id) return;
265
+ const dx = e.clientX - g.x;
266
+ const dy = e.clientY - g.y;
267
+ if (!dragMoved.current) {
268
+ if (Math.hypot(dx, dy) < CLICK_SLOP) return;
269
+ dragMoved.current = true;
270
+ el.setPointerCapture?.(e.pointerId);
271
+ }
272
+ if (g.mode === "pan") {
273
+ const s = viewRef.current?.s ?? g.start.s;
274
+ commit({ s, cx: g.start.cx - dx / s, cy: g.start.cy - dy / s }, true);
275
+ } else {
276
+ const [x0, y0] = local({ clientX: g.x, clientY: g.y });
277
+ const [x1, y1] = local(e);
278
+ setSelecting([Math.min(x0, x1), Math.min(y0, y1), Math.abs(x1 - x0), Math.abs(y1 - y0)]);
279
+ }
280
+ };
281
+ const up = (e: PointerEvent) => {
282
+ touches.delete(e.pointerId);
283
+ if (touches.size < 2) pinch = null;
284
+ if (!g || e.pointerId !== g.id) return;
285
+ const done = g;
286
+ g = null;
287
+ if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId);
288
+ if (done.mode === "box") {
289
+ setSelecting(null);
290
+ const v = viewRef.current;
291
+ if (dragMoved.current && v && e.type === "pointerup") {
292
+ const { w, h } = size();
293
+ const [x0, y0] = local({ clientX: done.x, clientY: done.y });
294
+ const [x1, y1] = local(e);
295
+ const ux = (x: number) => v.cx + (x - w / 2) / v.s;
296
+ const uy = (y: number) => v.cy + (y - h / 2) / v.s;
297
+ opts.current.onBox?.([ux(Math.min(x0, x1)), uy(Math.min(y0, y1)), Math.abs(x1 - x0) / v.s, Math.abs(y1 - y0) / v.s], done.additive);
298
+ }
299
+ }
300
+ };
301
+ // the middle button's click would paste on Linux, and its press start the browser's autoscroll
302
+ const aux = (e: MouseEvent) => e.button === 1 && e.preventDefault();
303
+ el.addEventListener("wheel", onWheel, { passive: false });
304
+ el.addEventListener("pointerdown", down);
305
+ el.addEventListener("pointermove", move);
306
+ el.addEventListener("pointerup", up);
307
+ el.addEventListener("pointercancel", up);
308
+ el.addEventListener("mousedown", aux);
309
+ el.addEventListener("auxclick", aux);
310
+ return () => {
311
+ el.removeEventListener("wheel", onWheel);
312
+ el.removeEventListener("pointerdown", down);
313
+ el.removeEventListener("pointermove", move);
314
+ el.removeEventListener("pointerup", up);
315
+ el.removeEventListener("pointercancel", up);
316
+ el.removeEventListener("mousedown", aux);
317
+ el.removeEventListener("auxclick", aux);
318
+ };
319
+ }, [el, zoomAt, commit, size]);
320
+
321
+ // the canvas's keys, when they are pressed on the page or inside this view, outside any field
322
+ useEffect(() => {
323
+ if (!el) return;
324
+ const ours = (e: KeyboardEvent) => {
325
+ const t = e.target as HTMLElement | null;
326
+ if (e.defaultPrevented || !el.isConnected || el.closest("[inert]")) return false;
327
+ if (t?.closest?.("input, textarea, select, [contenteditable='true'], [role='menu'], [role='listbox']")) return false;
328
+ const root = el.closest(".pb-board-drawing") ?? el;
329
+ return !t || t === document.body || t === document.documentElement || root.contains(t);
330
+ };
331
+ const taken = (e: KeyboardEvent) => {
332
+ e.preventDefault();
333
+ e.stopPropagation();
334
+ };
335
+ const keydown = (e: KeyboardEvent) => {
336
+ if (!ours(e)) return;
337
+ if (e.key === " " || e.code === "Space") {
338
+ if ((e.target as HTMLElement | null)?.closest?.("button, a[href], [role='tab']")) return;
339
+ e.preventDefault();
340
+ if (!e.repeat) dispatch({ type: "space", down: true });
341
+ return;
342
+ }
343
+ const c = canvasCommand(e);
344
+ if (!c) return;
345
+ switch (c.type) {
346
+ case "tool":
347
+ dispatch(c);
348
+ return taken(e);
349
+ case "fit":
350
+ fit();
351
+ return taken(e);
352
+ case "zoom":
353
+ if (c.by === "reset") {
354
+ const v = viewRef.current;
355
+ if (v) commit({ ...v, s: fitScale.current }, true);
356
+ } else zoomAt(c.by === "in" ? 1.4 : 1 / 1.4);
357
+ return taken(e);
358
+ case "escape":
359
+ opts.current.onEscape?.();
360
+ return;
361
+ default:
362
+ return;
363
+ }
364
+ };
365
+ const keyup = (e: KeyboardEvent) => {
366
+ if (e.key === " " || e.code === "Space") dispatch({ type: "space", down: false });
367
+ };
368
+ const blur = () => dispatch({ type: "blur" });
369
+ window.addEventListener("keydown", keydown, true);
370
+ window.addEventListener("keyup", keyup, true);
371
+ window.addEventListener("blur", blur);
372
+ return () => {
373
+ window.removeEventListener("keydown", keydown, true);
374
+ window.removeEventListener("keyup", keyup, true);
375
+ window.removeEventListener("blur", blur);
376
+ };
377
+ }, [el, fit, zoomAt, commit]);
378
+
379
+ return {
380
+ ref: setEl,
381
+ view,
382
+ fit,
383
+ zoomBy: (k) => zoomAt(k),
384
+ centerOn: (x, y, zoom = 4) => {
385
+ const v = viewRef.current;
386
+ if (v) commit({ cx: x, cy: y, s: Math.max(v.s, fitScale.current * zoom) }, true);
387
+ },
388
+ toDrawing: (x, y) => {
389
+ const v = viewRef.current;
390
+ if (!el || !v) return undefined;
391
+ const r = el.getBoundingClientRect();
392
+ return [v.cx + (x - r.left - r.width / 2) / v.s, v.cy + (y - r.top - r.height / 2) / v.s];
393
+ },
394
+ dragged: () => dragMoved.current,
395
+ tool: interaction.tool,
396
+ setTool: (tool) => dispatch({ type: "tool", tool }),
397
+ panning,
398
+ selecting,
399
+ };
400
+ }
package/src/icons.tsx ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Icons the views need that Lucide does not have, drawn on Lucide's grid
3
+ * (24 px, a 2 px round stroke, no fill) so they sit beside Lucide's in a tab
4
+ * strip, and `ViewIcon`, which draws a view's icon by name: one of these, else
5
+ * the Lucide icon of that name.
6
+ */
7
+ import type { SVGProps } from "react";
8
+ import { Icon } from "@deltarobotics/uhd-viewer/components";
9
+
10
+ /** The schematic view's icon: a resistor, the schematic symbol everyone reads (the PCB view has the circuit board). */
11
+ import { SCHEMATIC_ICON } from "./tabs.js";
12
+
13
+ export { SCHEMATIC_ICON };
14
+
15
+ /** Each custom icon's paths, in a 24 × 24 box. */
16
+ export const CUSTOM_ICONS: Record<string, readonly string[]> = {
17
+ [SCHEMATIC_ICON]: ["M2 12h2.5l1.5-4 3 8 3-8 3 8 3-8 1.5 4H22"],
18
+ };
19
+
20
+ type IconProps = {
21
+ name: string;
22
+ size?: number | string;
23
+ strokeWidth?: number | string;
24
+ absoluteStrokeWidth?: boolean;
25
+ } & Omit<SVGProps<SVGSVGElement>, "name" | "ref">;
26
+
27
+ /** A view's icon by name: a custom one (CUSTOM_ICONS), else Lucide's. */
28
+ export function ViewIcon({ name, size = 24, strokeWidth = 2, absoluteStrokeWidth, className, ...rest }: IconProps) {
29
+ const paths = CUSTOM_ICONS[name];
30
+ if (!paths)
31
+ return (
32
+ <Icon
33
+ name={name}
34
+ size={Number(size)}
35
+ strokeWidth={Number(strokeWidth)}
36
+ absoluteStrokeWidth={absoluteStrokeWidth}
37
+ className={className}
38
+ {...(rest as object)}
39
+ />
40
+ );
41
+ const sw = absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth;
42
+ return (
43
+ <svg
44
+ xmlns="http://www.w3.org/2000/svg"
45
+ width={size}
46
+ height={size}
47
+ viewBox="0 0 24 24"
48
+ fill="none"
49
+ stroke="currentColor"
50
+ strokeWidth={sw}
51
+ strokeLinecap="round"
52
+ strokeLinejoin="round"
53
+ className={["lucide", className].filter(Boolean).join(" ")}
54
+ {...rest}
55
+ >
56
+ {paths.map((d) => (
57
+ <path key={d} d={d} />
58
+ ))}
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ /** A custom icon as an SVG document in one colour (for hosts that need a file, such as an editor tab's icon). */
64
+ export function customIconSvg(name: string, color: string): string | undefined {
65
+ const paths = CUSTOM_ICONS[name];
66
+ if (!paths) return undefined;
67
+ const c = color.replace(/[^#a-zA-Z0-9(),.% ]/g, "");
68
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths.map((d) => `<path d="${d}"/>`).join("")}</svg>`;
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @protoboardai/views: Protoboard's views as components, the registry the
3
+ * shells' view tabs come from, and the host and theme adapter interfaces
4
+ * (docs/specs/viewer-stack.md). Shells: @protoboardai/web-viewer (browser,
5
+ * `protoboard dev`) and packages/vscode-protoboard.
6
+ */
7
+ export type { HostAdapter, Theme, ThemeAdapter, ViewDefinition, ViewProps, ViewStatus, ViewTarget } from "./types.js";
8
+ export { NULL_HOST } from "./types.js";
9
+ export { ViewRegistry, registerContributedViews } from "./registry.js";
10
+ export { appliesFromDecl, definition, fileName, hasArtifact, hasFile, subtree } from "./applies.js";
11
+ export { BUILTIN_TABS, tabsFor, type TabRule } from "./tabs.js";
12
+ export { AssemblyView, BUILTIN_VIEWS, DiagramView, MissingPartsHint, PlannedView, createViewRegistry } from "./builtins.js";
13
+ export { BoardDrawingView, PcbView, SchematicView } from "./drawing/BoardDrawingView.js";
14
+ export { themeSvg, canonicalColour, type Palette, type ThemedSvg } from "./drawing/svg.js";
15
+ export { PCB_PALETTE, SCHEMATIC_PALETTE, DRAWING_CSS } from "./drawing/palettes.js";
16
+ export { boardsWith, type BoardAt } from "./drawing/boards.js";
17
+ export { PairView } from "./pair/PairView.js";
18
+ export { CUSTOM_ICONS, SCHEMATIC_ICON, ViewIcon, customIconSvg } from "./icons.js";
19
+ export { pairsOf, findPair, censusOf, potentialsOf, type Pair, type PairLink, type PairSide, type CensusEntry } from "./pair/model.js";
20
+ export {
21
+ PROTOBOARD_THEMES,
22
+ THEME_CHOICES,
23
+ protoboardDark,
24
+ protoboardLight,
25
+ protoboardThemeAdapter,
26
+ themeAdapterFor,
27
+ vscodeThemeAdapter,
28
+ defaultThemeAdapter,
29
+ staticTheme,
30
+ type ThemeChoice,
31
+ } from "./theme.js";
32
+ export * from "./parts/index.js";
33
+ export * from "./inspector/index.js";