@protoboardai/views 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +21 -2
- package/package.json +57 -4
- package/src/applies.ts +54 -0
- package/src/builtins.tsx +89 -0
- package/src/drawing/BoardDrawingView.tsx +515 -0
- package/src/drawing/CircuitPcbView.tsx +494 -0
- package/src/drawing/DrawingControls.tsx +39 -0
- package/src/drawing/boards.ts +82 -0
- package/src/drawing/circuit.ts +413 -0
- package/src/drawing/kicadSchematic.ts +277 -0
- package/src/drawing/palettes.ts +150 -0
- package/src/drawing/svg.ts +445 -0
- package/src/drawing/usePanZoom.ts +400 -0
- package/src/icons.tsx +69 -0
- package/src/index.ts +33 -0
- package/src/inspector/DrcPanel.tsx +302 -0
- package/src/inspector/SelectionInspector.tsx +392 -0
- package/src/inspector/index.ts +9 -0
- package/src/inspector/model.ts +224 -0
- package/src/pair/PairView.tsx +314 -0
- package/src/pair/model.ts +206 -0
- package/src/parts/BomEditor.tsx +564 -0
- package/src/parts/CardPages.tsx +317 -0
- package/src/parts/CategoryList.tsx +72 -0
- package/src/parts/CloudPanel.tsx +160 -0
- package/src/parts/GlbPreview.tsx +116 -0
- package/src/parts/LibrariesManager.tsx +251 -0
- package/src/parts/LibraryBrowser.tsx +191 -0
- package/src/parts/LibraryPage.tsx +168 -0
- package/src/parts/PartCard.tsx +274 -0
- package/src/parts/PartDetail.tsx +613 -0
- package/src/parts/PartStatus.tsx +54 -0
- package/src/parts/Prices.tsx +241 -0
- package/src/parts/ProjectParts.tsx +174 -0
- package/src/parts/ProjectPartsPage.tsx +264 -0
- package/src/parts/SettingsPage.tsx +268 -0
- package/src/parts/Surface.tsx +51 -0
- package/src/parts/Windowed.tsx +70 -0
- package/src/parts/carousel.tsx +344 -0
- package/src/parts/index.ts +32 -0
- package/src/parts/library-data.ts +726 -0
- package/src/parts/matte.ts +69 -0
- package/src/parts/model.ts +590 -0
- package/src/parts/part-pages.ts +379 -0
- package/src/parts/part-status.ts +88 -0
- package/src/parts/revision.ts +11 -0
- package/src/parts/three.d.ts +5 -0
- package/src/parts/ui.tsx +335 -0
- package/src/parts/views.tsx +433 -0
- package/src/registry.ts +107 -0
- package/src/tabs.ts +118 -0
- package/src/theme.ts +225 -0
- package/src/types.ts +93 -0
- package/src/ui/Select.tsx +99 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A part as a card (PB-902), with its carousels (PB-918, designs 06 and 07):
|
|
3
|
+
* the pictures on top (or at the left of a row), the title, which never
|
|
4
|
+
* changes, and under it a text carousel that turns through the part's pages
|
|
5
|
+
* (overview, specs, dimensions, interfaces, CAD, artifacts) in a box of
|
|
6
|
+
* fixed size, so a card is the same size on every page. The actions are
|
|
7
|
+
* icons (Add part, Preview, Compare, more), with the project's state as a
|
|
8
|
+
* pill. `row` is the sidebar's and the list's; `tile` is the full page's grid.
|
|
9
|
+
*
|
|
10
|
+
* A card with focus turns its pages with ← and →, its pictures with Shift
|
|
11
|
+
* and the same keys; Enter opens the part. The pages' data is fetched when
|
|
12
|
+
* the reader first rests on the card or turns it (carousel.tsx).
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
|
15
|
+
import { Icon, cx } from "@deltarobotics/uhd-viewer/components";
|
|
16
|
+
import { Announce, CardPage, ImageCarousel } from "./CardPages.js";
|
|
17
|
+
import { Pager, Segments, stepIn, usePagerStore, usePartPages, useSwipe } from "./carousel.js";
|
|
18
|
+
import type { CardPageId, PartCardModel, PartDetailTab, PartImage } from "./model.js";
|
|
19
|
+
import { PAGE_ICON, PAGE_LABEL, availablePages } from "./part-pages.js";
|
|
20
|
+
import { rev } from "./revision.js";
|
|
21
|
+
import { BODY, Pill, RowAction, Tag } from "./ui.js";
|
|
22
|
+
|
|
23
|
+
export interface PartCardProps {
|
|
24
|
+
item: PartCardModel;
|
|
25
|
+
variant?: "row" | "tile";
|
|
26
|
+
selected?: boolean;
|
|
27
|
+
/** No project: Add is offered but disabled. */
|
|
28
|
+
noProject?: boolean;
|
|
29
|
+
onOpen(key: string, tab?: PartDetailTab): void;
|
|
30
|
+
onAdd(key: string): void;
|
|
31
|
+
onUpdate(key: string): void;
|
|
32
|
+
onMore?(key: string): void;
|
|
33
|
+
/** The card's state pill, in place of the library's "used" and "update" pills (the Project Parts page: the sync status). */
|
|
34
|
+
pill?: ReactNode;
|
|
35
|
+
/** The card's action icons, in place of the library's (Add, Preview, Compare). */
|
|
36
|
+
actions?: ReactNode;
|
|
37
|
+
/** Shown before the category line (the Project Parts page: the part's status dot). */
|
|
38
|
+
status?: ReactNode;
|
|
39
|
+
/** The picture's placeholder icon when the part has none. */
|
|
40
|
+
icon?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The project's state of a part, as a pill; an update's pill updates the part. */
|
|
44
|
+
export function UsePill({ item, solid, onUpdate, disabled }: { item: Pick<PartCardModel, "key" | "used" | "update" | "busy" | "deprecated">; solid?: boolean; onUpdate?: (key: string) => void; disabled?: boolean }) {
|
|
45
|
+
if (item.busy) return <Pill tone="neutral" solid={false}>{item.busy}…</Pill>;
|
|
46
|
+
if (item.update !== undefined) {
|
|
47
|
+
const pill = (
|
|
48
|
+
<Pill tone="info" solid={solid} icon="download" title={`The project pins ${rev(item.used?.revision)}; ${rev(item.update)} is recommended`}>
|
|
49
|
+
update {rev(item.update)}
|
|
50
|
+
</Pill>
|
|
51
|
+
);
|
|
52
|
+
if (!onUpdate) return pill;
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
aria-label={`Update part to ${rev(item.update)}`}
|
|
57
|
+
data-tooltip={`Update part to ${rev(item.update)}`}
|
|
58
|
+
disabled={disabled}
|
|
59
|
+
onClick={(e) => {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
onUpdate(item.key);
|
|
62
|
+
}}
|
|
63
|
+
className="cursor-pointer rounded-full focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus disabled:cursor-default"
|
|
64
|
+
>
|
|
65
|
+
{pill}
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (item.used)
|
|
70
|
+
return (
|
|
71
|
+
<Pill tone="ok" solid={solid} title={item.used.elsewhere ? "The project has this id from another library" : "In this project"}>
|
|
72
|
+
{item.used.elsewhere ? "used · other library" : `used ${rev(item.used.revision)}`}
|
|
73
|
+
</Pill>
|
|
74
|
+
);
|
|
75
|
+
if (item.deprecated) return <Pill tone="warning">deprecated</Pill>;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Actions({ item, noProject, onOpen, onAdd, onMore }: Omit<PartCardProps, "variant" | "selected">) {
|
|
80
|
+
const inProject = !!item.used && !item.used.elsewhere;
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
83
|
+
<RowAction icon="plus" label={inProject ? "In this project" : noProject ? "Add part (open a Protoboard project first)" : "Add part"} disabled={noProject || inProject || !!item.busy} onClick={() => onAdd(item.key)} />
|
|
84
|
+
<RowAction icon="eye" label="Preview part" onClick={() => onOpen(item.key)} />
|
|
85
|
+
<RowAction icon="git-compare-arrows" label="Compare revisions" onClick={() => onOpen(item.key, "revisions")} />
|
|
86
|
+
{onMore && <RowAction icon="ellipsis" label="More actions" onClick={() => onMore(item.key)} />}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const CATALOG_PICTURE = "Catalog picture";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A card's carousels: where it is (kept by the list's store), the pages it
|
|
95
|
+
* has, and its pictures. The pages are asked for once the card is turned,
|
|
96
|
+
* or the reader rests on it (so the first turn does not wait).
|
|
97
|
+
*/
|
|
98
|
+
function useCard(item: PartCardModel) {
|
|
99
|
+
const store = usePagerStore();
|
|
100
|
+
const asked = store.page(item.key);
|
|
101
|
+
const [rested, setRested] = useState(false);
|
|
102
|
+
const { state, available, retry } = usePartPages(item.pages, asked !== "overview" || rested);
|
|
103
|
+
const pages = state.status === "ready" ? state.pages : undefined;
|
|
104
|
+
const list: CardPageId[] = available ? availablePages(pages) : ["overview"];
|
|
105
|
+
// the way the reader last turned, so a page the part turns out not to have is passed over in that direction
|
|
106
|
+
const dir = useRef(1);
|
|
107
|
+
const [said, setSaid] = useState<string>();
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!pages || list.includes(asked) || store.isLinked) return;
|
|
110
|
+
const all = availablePages(undefined);
|
|
111
|
+
let next = asked;
|
|
112
|
+
for (let i = 0; i < all.length && !list.includes(next); i++) next = stepIn(all, next, dir.current);
|
|
113
|
+
store.setPage(item.key, next);
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [pages, asked]);
|
|
116
|
+
// while the cards turn together, a part without that page keeps to its overview
|
|
117
|
+
const page: CardPageId = list.includes(asked) ? asked : "overview";
|
|
118
|
+
const step = (delta: number) => {
|
|
119
|
+
if (list.length < 2) return;
|
|
120
|
+
dir.current = delta;
|
|
121
|
+
const next = stepIn(list, page, delta);
|
|
122
|
+
store.setPage(item.key, next);
|
|
123
|
+
setSaid(`${PAGE_LABEL[next]}, page ${list.indexOf(next) + 1} of ${list.length}`);
|
|
124
|
+
};
|
|
125
|
+
const images: PartImage[] = pages?.images.length ? pages.images : item.thumbnail ? [{ url: item.thumbnail, label: CATALOG_PICTURE }] : [];
|
|
126
|
+
const image = Math.min(store.image(item.key), Math.max(images.length - 1, 0));
|
|
127
|
+
const rest = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
128
|
+
useEffect(() => () => clearTimeout(rest.current), []);
|
|
129
|
+
return {
|
|
130
|
+
page,
|
|
131
|
+
list,
|
|
132
|
+
state,
|
|
133
|
+
retry,
|
|
134
|
+
step,
|
|
135
|
+
said,
|
|
136
|
+
dir: dir.current,
|
|
137
|
+
images,
|
|
138
|
+
image,
|
|
139
|
+
setImage: (i: number) => store.setImage(item.key, i),
|
|
140
|
+
stepImage: (d: number) => images.length > 1 && store.setImage(item.key, (image + d + images.length) % images.length),
|
|
141
|
+
/** The reader rests on the card (pointer or focus): its pages are fetched ahead of the first turn. */
|
|
142
|
+
hover: {
|
|
143
|
+
onPointerEnter: () => {
|
|
144
|
+
if (!rested && available) rest.current = setTimeout(() => setRested(true), 350);
|
|
145
|
+
},
|
|
146
|
+
onPointerLeave: () => clearTimeout(rest.current),
|
|
147
|
+
onFocus: () => available && setRested(true),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type Card = ReturnType<typeof useCard>;
|
|
153
|
+
|
|
154
|
+
/** The page's name and the pager: "ⓘ OVERVIEW ‹ 1/6 ›". */
|
|
155
|
+
function PageHeader({ card, className }: { card: Card; className?: string }) {
|
|
156
|
+
const n = card.list.length;
|
|
157
|
+
return (
|
|
158
|
+
<div className={cx("flex h-4 min-w-0 items-center gap-1.5", className)}>
|
|
159
|
+
<Icon name={PAGE_ICON[card.page]} size={11} strokeWidth={1.75} className="shrink-0 text-accent" aria-hidden />
|
|
160
|
+
<span className="min-w-0 flex-1 truncate font-ui text-[10px] leading-none tracking-[0.08em] text-fg-muted uppercase">{PAGE_LABEL[card.page]}</span>
|
|
161
|
+
{/* the card's own keys turn the pages, so the arrows stay out of the tab order: one stop per card, then its actions */}
|
|
162
|
+
{n > 1 && <Pager index={card.list.indexOf(card.page)} count={n} what="page" onStep={card.step} tabIndex={-1} />}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Enter opens the part; the arrows turn the pages, with Shift the pictures. Only when the card itself has the focus. */
|
|
168
|
+
const cardKeys = (open: () => void, card: Card) => (e: KeyboardEvent) => {
|
|
169
|
+
if (e.target !== e.currentTarget) return;
|
|
170
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
open();
|
|
173
|
+
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
174
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
const d = e.key === "ArrowRight" ? 1 : -1;
|
|
177
|
+
if (e.shiftKey) card.stepImage(d);
|
|
178
|
+
else card.step(d);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** A card's height before it is drawn (the list's window keeps offscreen cards as boxes of this size). */
|
|
183
|
+
export const CARD_HEIGHT = { tile: 300, row: 132 } as const;
|
|
184
|
+
|
|
185
|
+
export function PartCard(props: PartCardProps) {
|
|
186
|
+
const { item, variant = "row", selected, onOpen } = props;
|
|
187
|
+
const title = item.summary ? `${item.name} · ${item.summary}` : item.name;
|
|
188
|
+
const open = () => onOpen(item.key);
|
|
189
|
+
const card = useCard(item);
|
|
190
|
+
const swipe = useSwipe(card.step);
|
|
191
|
+
const turns = card.list.length > 1;
|
|
192
|
+
const label = `${title}${turns ? `. ${PAGE_LABEL[card.page]}, page ${card.list.indexOf(card.page) + 1} of ${card.list.length}` : ""}`;
|
|
193
|
+
const pageBox = <CardPage page={card.page} item={item} state={card.state} variant={variant} status={props.status} onRetry={card.retry} from={card.dir} />;
|
|
194
|
+
if (variant === "tile")
|
|
195
|
+
return (
|
|
196
|
+
<article
|
|
197
|
+
role="button"
|
|
198
|
+
tabIndex={0}
|
|
199
|
+
aria-label={label}
|
|
200
|
+
aria-keyshortcuts={turns ? "ArrowLeft ArrowRight" : undefined}
|
|
201
|
+
data-page={card.page}
|
|
202
|
+
onClick={open}
|
|
203
|
+
onKeyDown={cardKeys(open, card)}
|
|
204
|
+
{...card.hover}
|
|
205
|
+
className={cx(
|
|
206
|
+
BODY,
|
|
207
|
+
"pb-reveal-host group relative flex h-[300px] min-w-0 cursor-pointer flex-col overflow-hidden rounded-[6px] border bg-[var(--pb-card)] text-left transition-colors",
|
|
208
|
+
selected ? "border-focus" : "border-line hover:border-[var(--pb-control-border)]",
|
|
209
|
+
"outline-1 -outline-offset-1 outline-contrast hover:outline-dashed hover:outline-contrast-active focus-visible:outline-solid focus-visible:outline-focus",
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
<ImageCarousel images={card.images} alt={item.name} index={card.image} onIndex={card.setImage} className="h-[146px] w-full" iconSize={40} icon={props.icon}>
|
|
213
|
+
<div className="absolute top-2 right-2 z-[1]">{props.pill ?? <UsePill item={item} solid onUpdate={props.onUpdate} disabled={props.noProject} />}</div>
|
|
214
|
+
</ImageCarousel>
|
|
215
|
+
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2.5 pb-2">
|
|
216
|
+
<h3 className="h-[19px] shrink-0 truncate text-[13.5px] leading-[19px] font-semibold text-fg" title={title}>
|
|
217
|
+
{item.name}
|
|
218
|
+
</h3>
|
|
219
|
+
<PageHeader card={card} className="mt-1" />
|
|
220
|
+
<div className="mt-1.5 touch-pan-y" {...(turns ? swipe : {})}>
|
|
221
|
+
{pageBox}
|
|
222
|
+
</div>
|
|
223
|
+
<div className="mt-auto flex items-center gap-2">
|
|
224
|
+
<Segments index={card.list.indexOf(card.page)} count={card.list.length} />
|
|
225
|
+
<span className="ml-auto flex shrink-0">{props.actions ?? <Actions {...props} />}</span>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
<Announce text={card.said} />
|
|
229
|
+
</article>
|
|
230
|
+
);
|
|
231
|
+
return (
|
|
232
|
+
<article
|
|
233
|
+
role="button"
|
|
234
|
+
tabIndex={0}
|
|
235
|
+
aria-label={label}
|
|
236
|
+
aria-keyshortcuts={turns ? "ArrowLeft ArrowRight" : undefined}
|
|
237
|
+
aria-current={selected || undefined}
|
|
238
|
+
data-page={card.page}
|
|
239
|
+
onClick={open}
|
|
240
|
+
onKeyDown={cardKeys(open, card)}
|
|
241
|
+
{...card.hover}
|
|
242
|
+
className={cx(
|
|
243
|
+
BODY,
|
|
244
|
+
"pb-reveal-host flex min-h-[132px] min-w-0 cursor-pointer overflow-hidden rounded-[4px] border bg-[var(--pb-card)] text-left transition-colors",
|
|
245
|
+
selected ? "border-focus" : "border-line hover:border-[var(--pb-control-border)]",
|
|
246
|
+
"outline-1 -outline-offset-1 outline-contrast hover:outline-dashed hover:outline-contrast-active focus-visible:outline-solid focus-visible:outline-focus",
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
<ImageCarousel images={card.images} alt={item.name} index={card.image} onIndex={card.setImage} className="w-[88px] self-stretch max-[300px]:w-[64px]" icon={props.icon} arrow={18} />
|
|
250
|
+
<div className="flex min-w-0 flex-1 flex-col px-2.5 pt-2 pb-1">
|
|
251
|
+
<h3 className="line-clamp-2 text-[12.5px] leading-[1.25] font-semibold text-fg" title={title}>
|
|
252
|
+
{title}
|
|
253
|
+
</h3>
|
|
254
|
+
<PageHeader card={card} className="mt-1" />
|
|
255
|
+
<div className="mt-1 touch-pan-y" {...(turns ? swipe : {})}>
|
|
256
|
+
{pageBox}
|
|
257
|
+
</div>
|
|
258
|
+
{/* the pill never shrinks away (an update is an action): when it and the icons do not fit, the icons wrap below */}
|
|
259
|
+
<div className="mt-auto flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 pt-0.5">
|
|
260
|
+
<span className="flex shrink-0 empty:hidden">{props.pill ?? <UsePill item={item} onUpdate={props.onUpdate} disabled={props.noProject} />}</span>
|
|
261
|
+
{!props.pill && !item.used && !item.update && item.tags.length > 0 && (
|
|
262
|
+
<span className="flex min-w-0 flex-1 basis-0 items-center gap-1 overflow-hidden">
|
|
263
|
+
{item.tags.slice(0, 2).map((t) => (
|
|
264
|
+
<Tag key={t}>{t}</Tag>
|
|
265
|
+
))}
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
<span className="ml-auto flex shrink-0">{props.actions ?? <Actions {...props} />}</span>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
<Announce text={card.said} />
|
|
272
|
+
</article>
|
|
273
|
+
);
|
|
274
|
+
}
|