869 lines
32 KiB
TypeScript
869 lines
32 KiB
TypeScript
/**
|
|
* Component Picker Modal
|
|
*
|
|
* Modal interface for searching and selecting components from the wokwi-elements library.
|
|
* Features:
|
|
* - Search bar with real-time filtering
|
|
* - Category tabs for filtering
|
|
* - Grid layout with component thumbnails
|
|
* - Click to select and add component
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ComponentRegistry } from '../services/ComponentRegistry';
|
|
import type { ComponentMetadata, ComponentCategory } from '../types/component-metadata';
|
|
import { ComponentInfoPanel, HOVER_DELAY, type HoverTarget, type PanelData } from './ComponentInfoPanel';
|
|
|
|
// Grace period after the pointer leaves a card before the datasheet popover
|
|
// hides — long enough to cross the gap onto the (interactive) panel. Must be
|
|
// comfortably larger than HOVER_DELAY so re-entering a card cancels the hide
|
|
// before it fires.
|
|
const HIDE_DELAY = 220;
|
|
|
|
/** Hover controls handed to each card so it can drive the shared popover. */
|
|
interface CardHoverApi {
|
|
show: (t: HoverTarget) => void;
|
|
cancelHide: () => void;
|
|
scheduleHide: () => void;
|
|
/** Current show generation — an armed show is void once this changes. */
|
|
showGen: () => number;
|
|
}
|
|
import type { BoardKind } from '../types/board';
|
|
import { BOARD_KIND_LABELS } from '../types/board';
|
|
import { isProBoardKind } from '../lib/proBoardGate';
|
|
import {
|
|
getProBoard,
|
|
listProBoards,
|
|
subscribeProBoards,
|
|
getProBoardsVersion,
|
|
} from '../lib/proBoardRegistry';
|
|
import {
|
|
ONLINE_ONLY_BOARD_ADS,
|
|
ONLINE_ONLY_COMPONENT_ADS,
|
|
ONLINE_EDITOR_URL,
|
|
type OnlineOnlyBoardAd,
|
|
type OnlineOnlyComponentAd,
|
|
} from '../lib/onlineOnlyBoards';
|
|
import raspberryPi3Svg from '../assets/Raspberry_Pi_3_illustration.svg';
|
|
import raspberryPi4Png from '../assets/raspberry-pi-4-board.png';
|
|
import raspberryPi5Png from '../assets/raspberry-pi-5-board.png';
|
|
import { Attiny85 } from './velxio-components/Attiny85';
|
|
import './velxio-components/Esp32Element'; // registers velxio-esp32
|
|
import './velxio-components/PiPicoWElement'; // registers velxio-pi-pico-w
|
|
import './velxio-components/Stm32BluePillElement'; // registers velxio-stm32-bluepill
|
|
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin
|
|
// Register every wokwi tag that the picker might try to instantiate as a
|
|
// thumbnail. The picker calls `document.createElement(tagName)`, so any tag
|
|
// that isn't already a registered custom element renders as an empty
|
|
// HTMLUnknownElement (blank card preview).
|
|
import '@wokwi/elements';
|
|
import '../velxio-elements';
|
|
import './ComponentPickerModal.css';
|
|
|
|
interface ComponentPickerModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSelectComponent: (metadata: ComponentMetadata) => void;
|
|
onSelectBoard?: (kind: BoardKind) => void;
|
|
}
|
|
|
|
const BOARD_DESCRIPTIONS: Record<BoardKind, string> = {
|
|
'arduino-uno': '8-bit AVR, 32KB flash, 14 digital I/O',
|
|
'arduino-nano': 'Compact 8-bit AVR, same as Uno',
|
|
'arduino-mega': '8-bit AVR, 256KB flash, 54 digital I/O',
|
|
'raspberry-pi-pico': 'RP2040 dual-core Cortex-M0+',
|
|
'pi-pico-w': 'RP2040 + WiFi/BT, same emulator as Pico',
|
|
'raspberry-pi-3': 'ARM64 Cortex-A53 quad-core, Linux/Python (QEMU)',
|
|
'raspberry-pi-4': 'ARM64 Cortex-A72 quad-core, Linux/Python (QEMU)',
|
|
'raspberry-pi-5': 'ARM64 Cortex-A76 quad-core + RP1 I/O, Linux/Python (QEMU)',
|
|
esp32: 'Xtensa LX6 dual-core, WiFi+BT, 38 GPIO (QEMU)',
|
|
'esp32-devkit-c-v4': 'ESP32 DevKit C V4, official Espressif (QEMU)',
|
|
'esp32-cam': 'ESP32 + 2MP camera, microSD (QEMU)',
|
|
'wemos-lolin32-lite': 'Compact ESP32, LiPo battery support (QEMU)',
|
|
'esp32-s3': 'Xtensa LX7 dual-core, WiFi+BT, AI accel (QEMU)',
|
|
'xiao-esp32-s3': 'Seeed XIAO tiny form, 8MB flash+PSRAM (QEMU)',
|
|
'arduino-nano-esp32': 'Nano form-factor, ESP32-S3, RGB LED (QEMU)',
|
|
'esp32-c3': 'RISC-V single-core, WiFi+BLE, 22 GPIO (QEMU)',
|
|
'xiao-esp32-c3': 'Seeed XIAO ESP32-C3 mini board (QEMU)',
|
|
'aitewinrobot-esp32c3-supermini': 'ESP32-C3 SuperMini (QEMU)',
|
|
'stm32-bluepill': 'STM32F103C8 Cortex-M3, 64KB flash, 37 GPIO (QEMU)',
|
|
'stm32-blackpill': 'STM32F411CE Cortex-M4, 512KB flash, 50 GPIO (QEMU)',
|
|
'stm32-bluepill-f103cb': 'STM32F103CB Cortex-M3, 128KB flash, 37 GPIO (QEMU)',
|
|
'stm32-blackpill-f401': 'STM32F401CE Cortex-M4, 512KB flash, 50 GPIO (QEMU)',
|
|
'stm32-f4-discovery': 'STM32F407VG Cortex-M4, 1MB flash, 4 onboard LEDs (QEMU)',
|
|
'stm32-olimex-h405': 'Olimex STM32-H405, F405RG Cortex-M4, 1MB flash (QEMU)',
|
|
'stm32-netduino-plus2': 'Netduino Plus 2, STM32F405 Cortex-M4 (QEMU)',
|
|
'stm32-netduino2': 'Netduino 2, STM32F205 Cortex-M3 (QEMU, serial)',
|
|
attiny85: '8-bit AVR, 8KB flash, 6 GPIO (browser)',
|
|
};
|
|
|
|
const ALL_BOARDS: BoardKind[] = [
|
|
'arduino-uno',
|
|
'arduino-nano',
|
|
'arduino-mega',
|
|
'raspberry-pi-pico',
|
|
'pi-pico-w',
|
|
'raspberry-pi-3',
|
|
'raspberry-pi-4',
|
|
'raspberry-pi-5',
|
|
'esp32',
|
|
'esp32-devkit-c-v4',
|
|
'esp32-cam',
|
|
'wemos-lolin32-lite',
|
|
'esp32-s3',
|
|
'xiao-esp32-s3',
|
|
'arduino-nano-esp32',
|
|
'esp32-c3',
|
|
'xiao-esp32-c3',
|
|
'aitewinrobot-esp32c3-supermini',
|
|
'stm32-bluepill',
|
|
'stm32-blackpill',
|
|
'stm32-bluepill-f103cb',
|
|
'stm32-blackpill-f401',
|
|
'stm32-f4-discovery',
|
|
'stm32-olimex-h405',
|
|
'stm32-netduino-plus2',
|
|
'stm32-netduino2',
|
|
'attiny85',
|
|
];
|
|
|
|
export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
onSelectComponent,
|
|
onSelectBoard,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState<ComponentCategory | 'all' | 'boards'>(
|
|
'all',
|
|
);
|
|
const [registry] = useState(() => ComponentRegistry.getInstance());
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
// Floating datasheet popover shown on card hover. A single instance is
|
|
// driven from here so only one panel ever exists in the DOM. Hiding is
|
|
// DEFERRED through a grace-period timer so the pointer can travel from the
|
|
// card onto the panel (to scroll a long doc or click Buy) without it
|
|
// vanishing: the card's leave arms the hide, the panel's enter cancels it.
|
|
const [hoverTarget, setHoverTarget] = useState<HoverTarget | null>(null);
|
|
const hideTimer = useRef<number | undefined>(undefined);
|
|
// Monotonic generation stamped when a card arms its show timer. Bumping it
|
|
// invalidates any already-armed show so a grid change (scroll/filter/search)
|
|
// can't pop a panel at a now-stale card rect after the pointer's card reflows.
|
|
const showGenRef = useRef(0);
|
|
const cancelHide = () => window.clearTimeout(hideTimer.current);
|
|
const showPanel = (t: HoverTarget) => {
|
|
window.clearTimeout(hideTimer.current);
|
|
setHoverTarget(t);
|
|
};
|
|
const scheduleHide = () => {
|
|
window.clearTimeout(hideTimer.current);
|
|
hideTimer.current = window.setTimeout(() => setHoverTarget(null), HIDE_DELAY);
|
|
};
|
|
// Immediate hide — used when the grid itself changes under the pointer
|
|
// (scroll, filter, search) so a stale panel never lingers. Also invalidates
|
|
// any armed (not-yet-fired) show timer.
|
|
const clearHover = () => {
|
|
showGenRef.current++;
|
|
window.clearTimeout(hideTimer.current);
|
|
setHoverTarget(null);
|
|
};
|
|
const hoverApi: CardHoverApi = {
|
|
show: showPanel,
|
|
cancelHide,
|
|
scheduleHide,
|
|
showGen: () => showGenRef.current,
|
|
};
|
|
// Clear any pending hide timer if the modal unmounts mid-hover.
|
|
useEffect(() => () => window.clearTimeout(hideTimer.current), []);
|
|
// The modal stays mounted (parent toggles `isOpen`), so reset the popover
|
|
// when it closes — otherwise a panel left showing at close (e.g. clicking a
|
|
// card to add it, or ESC while hovering) reappears detached on reopen.
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
showGenRef.current++;
|
|
window.clearTimeout(hideTimer.current);
|
|
setHoverTarget(null);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Wait for registry to load
|
|
useEffect(() => {
|
|
const loadRegistry = async () => {
|
|
await registry.load();
|
|
setIsLoading(false);
|
|
};
|
|
loadRegistry();
|
|
}, [registry]);
|
|
|
|
// Filter components based on search and category
|
|
const filteredComponents = useMemo(() => {
|
|
if (isLoading) return [];
|
|
|
|
let components = searchQuery ? registry.search(searchQuery) : registry.getAllComponents();
|
|
|
|
if (selectedCategory !== 'all') {
|
|
components = components.filter((c) => c.category === selectedCategory);
|
|
}
|
|
|
|
return components;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchQuery, selectedCategory, registry, isLoading, registryVersion]);
|
|
|
|
// Late-overlay registrations must re-render an already-mounted picker:
|
|
// the @pro import is dynamic, so boards/components can register AFTER the
|
|
// first render. Without these subscriptions the memos below freeze on the
|
|
// pre-registration state (boards missing, ONLINE ads instead of the real
|
|
// components - and which one you got depended on a reload race).
|
|
const proBoardsVersion = useSyncExternalStore(
|
|
subscribeProBoards,
|
|
getProBoardsVersion,
|
|
getProBoardsVersion,
|
|
);
|
|
const registryVersion = useSyncExternalStore(
|
|
registry.subscribe,
|
|
registry.getVersion,
|
|
registry.getVersion,
|
|
);
|
|
|
|
// Boards list: static OSS kinds + overlay-registered boards (proBoardRegistry).
|
|
const allBoards = useMemo(() => {
|
|
return [...ALL_BOARDS, ...(listProBoards().map((d) => d.kind) as BoardKind[])];
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [proBoardsVersion]);
|
|
|
|
// Online-only component ads: shown where the real component would sit, and
|
|
// hidden automatically in any build whose registry has the real component
|
|
// (the hosted overlay merges it in) — same contract as VISIBLE_BOARD_ADS.
|
|
const visibleComponentAds = useMemo(() => {
|
|
if (isLoading) return [];
|
|
const q = searchQuery.toLowerCase();
|
|
return ONLINE_ONLY_COMPONENT_ADS.filter(
|
|
(ad) =>
|
|
!registry.getById(ad.id) &&
|
|
(selectedCategory === 'all' || ad.category === selectedCategory) &&
|
|
(!q || ad.label.toLowerCase().includes(q)),
|
|
);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [registry, isLoading, searchQuery, selectedCategory, registryVersion, proBoardsVersion]);
|
|
|
|
// Get available categories
|
|
const categories = useMemo(() => {
|
|
if (isLoading) return [];
|
|
return registry.getCategories();
|
|
}, [registry, isLoading]);
|
|
|
|
// Handle ESC key to close modal
|
|
useEffect(() => {
|
|
const handleEsc = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
|
|
if (isOpen) {
|
|
window.addEventListener('keydown', handleEsc);
|
|
return () => window.removeEventListener('keydown', handleEsc);
|
|
}
|
|
}, [isOpen, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// Portal to <body>: the picker must escape the canvas subtree so no ancestor
|
|
// stacking context can pin it below floating panels (e.g. the AI chat).
|
|
return createPortal(
|
|
<div className="component-picker-overlay" onClick={onClose}>
|
|
<div className="component-picker-modal" onClick={(e) => e.stopPropagation()}>
|
|
{/* Header: title + inline search + category filter + close, all on
|
|
one row to maximise the space left for the components grid. */}
|
|
<div className="modal-header">
|
|
<h2>{t('editor.componentPicker.title')}</h2>
|
|
|
|
<div className="header-search-wrapper">
|
|
<svg
|
|
className="search-icon"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
className="search-input"
|
|
placeholder={t('editor.componentPicker.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
clearHover();
|
|
}}
|
|
autoFocus
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
className="clear-search-btn"
|
|
onClick={() => setSearchQuery('')}
|
|
aria-label={t('editor.componentPicker.clearSearch')}
|
|
>
|
|
X
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<select
|
|
className="category-select"
|
|
value={selectedCategory}
|
|
onChange={(e) => {
|
|
setSelectedCategory(e.target.value as ComponentCategory | 'all' | 'boards');
|
|
clearHover();
|
|
}}
|
|
aria-label="Filter by category"
|
|
>
|
|
<option value="all">{t('editor.componentPicker.allComponents')}</option>
|
|
{categories
|
|
.filter((c) => c !== 'boards')
|
|
.map((category) => (
|
|
<option key={category} value={category}>
|
|
{ComponentRegistry.getCategoryDisplayName(category)}
|
|
</option>
|
|
))}
|
|
{onSelectBoard && (
|
|
<option value="boards">{t('editor.componentPicker.boards')}</option>
|
|
)}
|
|
</select>
|
|
|
|
<button className="close-btn" onClick={onClose} aria-label={t('editor.componentPicker.close')}>
|
|
X
|
|
</button>
|
|
</div>
|
|
|
|
{/* Boards Panel */}
|
|
{selectedCategory === 'boards' ? (
|
|
<div className="components-grid" onScroll={clearHover}>
|
|
{allBoards.map((kind) => (
|
|
<BoardCard
|
|
key={kind}
|
|
kind={kind}
|
|
onSelect={() => {
|
|
onSelectBoard?.(kind);
|
|
onClose();
|
|
}}
|
|
hoverApi={hoverApi}
|
|
/>
|
|
))}
|
|
{visibleBoardAds().map((ad) => (
|
|
<OnlineOnlyBoardCard key={ad.id} ad={ad} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Single scrollable area wrapping both the boards row (only in
|
|
"All Components" view) and the components grid, so the modal
|
|
shows ONE scrollbar instead of two stacked ones. */}
|
|
<div className="components-scroll" onScroll={clearHover}>
|
|
{selectedCategory === 'all' && onSelectBoard && (
|
|
<div
|
|
className="components-grid components-grid--inline"
|
|
style={{ borderBottom: '1px solid #333', paddingBottom: 8, marginBottom: 4 }}
|
|
>
|
|
{allBoards.filter(
|
|
(k) =>
|
|
!searchQuery ||
|
|
BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase()),
|
|
).map((kind) => (
|
|
<BoardCard
|
|
key={kind}
|
|
kind={kind}
|
|
onSelect={() => {
|
|
onSelectBoard(kind);
|
|
onClose();
|
|
}}
|
|
hoverApi={hoverApi}
|
|
/>
|
|
))}
|
|
{visibleBoardAds().filter(
|
|
(ad) =>
|
|
!searchQuery || ad.label.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
).map((ad) => (
|
|
<OnlineOnlyBoardCard key={ad.id} ad={ad} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="components-grid components-grid--inline">
|
|
{isLoading ? (
|
|
<div className="loading-state">
|
|
<div className="spinner"></div>
|
|
<p>{t('editor.componentPicker.loading')}</p>
|
|
</div>
|
|
) : filteredComponents.length === 0 && visibleComponentAds.length === 0 ? (
|
|
<div className="no-results">
|
|
<p>{t('editor.componentPicker.noResults')}</p>
|
|
{searchQuery && (
|
|
<button
|
|
className="clear-filters-btn"
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setSelectedCategory('all');
|
|
}}
|
|
>
|
|
{t('editor.componentPicker.clearFilters')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
filteredComponents.map((component) => (
|
|
<ComponentCard
|
|
key={component.id}
|
|
component={component}
|
|
hoverApi={hoverApi}
|
|
onSelect={() => {
|
|
// Pro overlays can intercept clicks on pro_only
|
|
// components by setting window.__velxio_pro_gate__.
|
|
// Returning true means "handled — do not pass through".
|
|
if (component.pro_only) {
|
|
const gate = (window as unknown as {
|
|
__velxio_pro_gate__?: (c: typeof component) => boolean;
|
|
}).__velxio_pro_gate__;
|
|
if (gate && gate(component)) return;
|
|
}
|
|
onSelectComponent(component);
|
|
}}
|
|
/>
|
|
))
|
|
)}
|
|
{!isLoading &&
|
|
visibleComponentAds.map((ad) => (
|
|
<OnlineOnlyComponentCard key={ad.id} ad={ad} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer Info */}
|
|
<div className="modal-footer">
|
|
<span className="component-count">
|
|
{filteredComponents.length} component{filteredComponents.length !== 1 ? 's' : ''}{' '}
|
|
available
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Floating datasheet popover (portals to <body>). Keyed on the anchor
|
|
so it remounts per card and re-measures its position cleanly. */}
|
|
{hoverTarget && (
|
|
<ComponentInfoPanel
|
|
key={`${hoverTarget.data.name}-${Math.round(hoverTarget.rect.left)}-${Math.round(
|
|
hoverTarget.rect.top,
|
|
)}`}
|
|
target={hoverTarget}
|
|
onPanelEnter={cancelHide}
|
|
onPanelLeave={scheduleHide}
|
|
/>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Shared hover behaviour for the picker cards: on enter/focus cancel any
|
|
* pending hide and arm a delayed "show panel"; on leave/blur cancel that arm
|
|
* and hand off to the modal's grace-period hide (so the pointer can travel
|
|
* onto the panel). Always clears its own arm timer on unmount.
|
|
*/
|
|
function useCardHover(buildData: () => PanelData, api: CardHoverApi) {
|
|
const timer = useRef<number | undefined>(undefined);
|
|
const start = (e: React.MouseEvent | React.FocusEvent) => {
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
api.cancelHide();
|
|
window.clearTimeout(timer.current);
|
|
const gen = api.showGen();
|
|
timer.current = window.setTimeout(() => {
|
|
// Voided if a grid change (clearHover) bumped the generation meanwhile.
|
|
if (api.showGen() !== gen) return;
|
|
api.show({ data: buildData(), rect });
|
|
}, HOVER_DELAY);
|
|
};
|
|
const end = () => {
|
|
window.clearTimeout(timer.current);
|
|
api.scheduleHide();
|
|
};
|
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
|
return { onMouseEnter: start, onMouseLeave: end, onFocus: start, onBlur: end };
|
|
}
|
|
|
|
/**
|
|
* Component Card - Individual component display in the grid
|
|
*/
|
|
interface ComponentCardProps {
|
|
component: ComponentMetadata;
|
|
onSelect: () => void;
|
|
hoverApi: CardHoverApi;
|
|
}
|
|
|
|
// Passive components (resistor / capacitor / inductor) come with metadata
|
|
// thumbnails that already encode the preset value (color bands for resistors,
|
|
// value labels for caps/inductors). The live wokwi elements either ignore
|
|
// `value` visually or render it identically across presets, so for these we
|
|
// short-circuit to the SVG. Everything else still uses the live element so
|
|
// LEDs, displays, etc. preview correctly.
|
|
const PASSIVE_TAGS = new Set([
|
|
'wokwi-resistor',
|
|
'wokwi-capacitor',
|
|
'velxio-capacitor-electrolytic',
|
|
'wokwi-inductor',
|
|
]);
|
|
|
|
// Static illustrations for the Pi Linux family. A live velxio-raspberry-pi-*
|
|
// custom element at natural size + CSS scale keeps its unscaled layout box,
|
|
// so the 100px thumbnail clips it to a sliver — images render fully instead.
|
|
// Keyed by tagName because the registry's Pi Zero/1/2 entries deliberately
|
|
// reuse the Pi 3 board art.
|
|
const PI_BOARD_ART: Record<string, string> = {
|
|
'velxio-raspberry-pi-3': raspberryPi3Svg,
|
|
'velxio-raspberry-pi-4': raspberryPi4Png,
|
|
'velxio-raspberry-pi-5': raspberryPi5Png,
|
|
};
|
|
|
|
/** Gold PRO pill shown on cards for paid-gated boards (Pi Linux + STM32). */
|
|
const ProBadge: React.FC = () => (
|
|
<span
|
|
title="Pro feature — paid plan or Velxio Desktop"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 8,
|
|
right: 8,
|
|
zIndex: 1,
|
|
padding: '3px 10px',
|
|
borderRadius: 999,
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
letterSpacing: 0.6,
|
|
color: '#1a1205',
|
|
background: 'linear-gradient(180deg,#ffd566,#f5a623)',
|
|
boxShadow: '0 1px 4px rgba(0,0,0,0.35)',
|
|
}}
|
|
>
|
|
PRO
|
|
</span>
|
|
);
|
|
|
|
const ComponentCard: React.FC<ComponentCardProps> = ({ component, onSelect, hoverApi }) => {
|
|
const thumbnailRef = React.useRef<HTMLDivElement>(null);
|
|
const hover = useCardHover(
|
|
() => ({
|
|
id: component.id,
|
|
name: component.name,
|
|
category: ComponentRegistry.getCategoryDisplayName(component.category),
|
|
description: component.description,
|
|
pinCount: component.pinCount,
|
|
properties: component.properties,
|
|
tags: component.tags,
|
|
thumbnail: component.thumbnail,
|
|
pro_only: component.pro_only,
|
|
}),
|
|
hoverApi,
|
|
);
|
|
// Passives short-circuit to their preset SVG (value-encoded look).
|
|
const usePresetSvg =
|
|
PASSIVE_TAGS.has(component.tagName) &&
|
|
typeof component.thumbnail === 'string' &&
|
|
component.thumbnail.trim().startsWith('<svg');
|
|
const boardArt = PI_BOARD_ART[component.tagName];
|
|
|
|
// Render actual web component as thumbnail
|
|
React.useEffect(() => {
|
|
if (!thumbnailRef.current) return;
|
|
if (usePresetSvg) return; // SVG is rendered via dangerouslySetInnerHTML below
|
|
if (boardArt) return; // static illustration rendered below
|
|
|
|
// Create the actual wokwi element
|
|
const element = document.createElement(component.tagName);
|
|
|
|
// Scale factors for different component types
|
|
let scale = 0.5;
|
|
if (component.tagName.includes('arduino') || component.tagName.includes('esp32')) {
|
|
scale = 0.35; // Boards are larger, scale them down more
|
|
} else if (component.tagName.includes('lcd') || component.tagName.includes('display')) {
|
|
scale = 0.4; // Displays need a bit more space
|
|
}
|
|
|
|
(element as HTMLElement).style.transform = `scale(${scale})`;
|
|
(element as HTMLElement).style.transformOrigin = 'center center';
|
|
|
|
// Pass the preset's defaults through so variant-sensitive elements render
|
|
// the right look in the picker — e.g. wokwi-resistor color bands (value)
|
|
// or the M5Stack Chain matrix light/dark housing (mono). Same property
|
|
// assignment DynamicComponent performs when the part is placed, so any
|
|
// element that tolerates placement tolerates the preview.
|
|
for (const [key, val] of Object.entries(component.defaultValues ?? {})) {
|
|
try {
|
|
(element as any)[key] = val;
|
|
} catch {
|
|
/* read-only prop on some upstream element — skip */
|
|
}
|
|
}
|
|
|
|
// Set default properties for better preview appearance
|
|
if (component.tagName === 'wokwi-led') {
|
|
(element as any).value = true; // Turn on LED
|
|
(element as any).color = component.defaultValues?.color || 'red';
|
|
} else if (component.tagName === 'wokwi-rgb-led') {
|
|
(element as any).red = true;
|
|
(element as any).green = true;
|
|
(element as any).blue = true;
|
|
} else if (component.tagName === 'wokwi-pushbutton') {
|
|
(element as any).color = component.defaultValues?.color || 'red';
|
|
} else if (component.tagName === 'wokwi-lcd1602' || component.tagName === 'wokwi-lcd2004') {
|
|
(element as any).text = 'Hello World!';
|
|
}
|
|
|
|
thumbnailRef.current.innerHTML = '';
|
|
thumbnailRef.current.appendChild(element);
|
|
|
|
return () => {
|
|
if (thumbnailRef.current) {
|
|
thumbnailRef.current.innerHTML = '';
|
|
}
|
|
};
|
|
}, [component.tagName, component.defaultValues, usePresetSvg, boardArt]);
|
|
|
|
return (
|
|
<button className="component-card" onClick={onSelect} style={{ position: 'relative' }} {...hover}>
|
|
{isProBoardKind(component.id) && <ProBadge />}
|
|
<div className="card-thumbnail">
|
|
{boardArt ? (
|
|
<img
|
|
src={boardArt}
|
|
alt={component.name}
|
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
|
/>
|
|
) : usePresetSvg ? (
|
|
<div
|
|
className="component-preview"
|
|
dangerouslySetInnerHTML={{ __html: component.thumbnail }}
|
|
/>
|
|
) : (
|
|
<div ref={thumbnailRef} className="component-preview" />
|
|
)}
|
|
</div>
|
|
<div className="card-content">
|
|
<div className="card-name">{component.name}</div>
|
|
{component.description && <div className="card-description">{component.description}</div>}
|
|
<div className="card-meta">
|
|
<span className="card-category">{component.category}</span>
|
|
{component.pinCount > 0 && <span className="card-pins">{component.pinCount} pins</span>}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// Tag name used to render a thumbnail for each board kind.
|
|
// Boards without a tag will show a generic chip icon.
|
|
const BOARD_TAG: Partial<Record<BoardKind, string>> = {
|
|
'arduino-uno': 'wokwi-arduino-uno',
|
|
'arduino-nano': 'wokwi-arduino-nano',
|
|
'arduino-mega': 'wokwi-arduino-mega',
|
|
'raspberry-pi-pico': 'wokwi-nano-rp2040-connect',
|
|
'pi-pico-w': 'velxio-pi-pico-w',
|
|
esp32: 'velxio-esp32',
|
|
'esp32-devkit-c-v4': 'velxio-esp32',
|
|
'esp32-cam': 'velxio-esp32',
|
|
'wemos-lolin32-lite': 'velxio-esp32',
|
|
'esp32-s3': 'velxio-esp32',
|
|
'xiao-esp32-s3': 'velxio-esp32',
|
|
'arduino-nano-esp32': 'velxio-esp32',
|
|
'esp32-c3': 'velxio-esp32',
|
|
'xiao-esp32-c3': 'velxio-esp32',
|
|
'aitewinrobot-esp32c3-supermini': 'velxio-esp32',
|
|
'stm32-bluepill': 'velxio-stm32-bluepill',
|
|
'stm32-blackpill': 'velxio-stm32-blackpill',
|
|
'stm32-bluepill-f103cb': 'velxio-stm32-bluepill-f103cb',
|
|
'stm32-blackpill-f401': 'velxio-stm32-blackpill-f401',
|
|
'stm32-f4-discovery': 'velxio-stm32-f4-discovery',
|
|
'stm32-olimex-h405': 'velxio-stm32-olimex-h405',
|
|
'stm32-netduino-plus2': 'velxio-stm32-netduino-plus2',
|
|
'stm32-netduino2': 'velxio-stm32-netduino2',
|
|
};
|
|
|
|
interface BoardCardProps {
|
|
kind: BoardKind;
|
|
onSelect: () => void;
|
|
hoverApi: CardHoverApi;
|
|
}
|
|
|
|
const BoardCard: React.FC<BoardCardProps> = ({ kind, onSelect, hoverApi }) => {
|
|
const thumbnailRef = React.useRef<HTMLDivElement>(null);
|
|
const hover = useCardHover(
|
|
() => ({
|
|
id: kind,
|
|
name: BOARD_KIND_LABELS[kind],
|
|
category: 'Boards',
|
|
description: BOARD_DESCRIPTIONS[kind] ?? getProBoard(kind)?.description ?? '',
|
|
pinCount: 0,
|
|
properties: [],
|
|
tags: [],
|
|
pro_only: isProBoardKind(kind),
|
|
}),
|
|
hoverApi,
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!thumbnailRef.current) return;
|
|
// Static-image boards handled below via reactThumbnail: the whole Pi
|
|
// Linux family uses board illustrations (a live custom element at
|
|
// natural size + CSS scale keeps its unscaled layout box, so the
|
|
// 100px thumbnail clips it to a narrow sliver).
|
|
if (
|
|
kind === 'raspberry-pi-3' ||
|
|
kind === 'raspberry-pi-4' ||
|
|
kind === 'raspberry-pi-5' ||
|
|
kind === 'attiny85'
|
|
)
|
|
return;
|
|
|
|
const tag = BOARD_TAG[kind] ?? getProBoard(kind)?.tag;
|
|
if (!tag) return;
|
|
|
|
const el = document.createElement(tag) as HTMLElement;
|
|
// Use setAttribute so observedAttributes + connectedCallback read the correct value
|
|
el.setAttribute('board-kind', kind);
|
|
el.style.transform = 'scale(0.28)';
|
|
el.style.transformOrigin = 'center center';
|
|
|
|
thumbnailRef.current.innerHTML = '';
|
|
thumbnailRef.current.appendChild(el);
|
|
|
|
return () => {
|
|
if (thumbnailRef.current) thumbnailRef.current.innerHTML = '';
|
|
};
|
|
}, [kind]);
|
|
|
|
const reactThumbnail =
|
|
kind === 'raspberry-pi-3' ? (
|
|
<img
|
|
src={raspberryPi3Svg}
|
|
alt="Raspberry Pi 3"
|
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
|
/>
|
|
) : kind === 'raspberry-pi-4' ? (
|
|
<img
|
|
src={raspberryPi4Png}
|
|
alt="Raspberry Pi 4"
|
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
|
/>
|
|
) : kind === 'raspberry-pi-5' ? (
|
|
<img
|
|
src={raspberryPi5Png}
|
|
alt="Raspberry Pi 5"
|
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
|
/>
|
|
) : kind === 'attiny85' ? (
|
|
<div style={{ transform: 'scale(0.55)', transformOrigin: 'center center' }}>
|
|
<Attiny85 />
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<button
|
|
className="component-card"
|
|
onClick={onSelect}
|
|
style={{ position: 'relative' }}
|
|
{...hover}
|
|
>
|
|
{isProBoardKind(kind) && <ProBadge />}
|
|
<div className="card-thumbnail">
|
|
{reactThumbnail ? reactThumbnail : <div ref={thumbnailRef} className="component-preview" />}
|
|
</div>
|
|
<div className="card-content">
|
|
<div className="card-name">{BOARD_KIND_LABELS[kind]}</div>
|
|
<div className="card-description">{BOARD_DESCRIPTIONS[kind] ?? getProBoard(kind)?.description}</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// ── Online-only board ads ───────────────────────────────────────────────────
|
|
// Boards implemented by the hosted editor (velxio.com), free to use there.
|
|
// Hidden automatically in any build that registers the real BoardKind.
|
|
/** Recomputed on access (not module load): overlay board registration patches
|
|
* BOARD_KIND_LABELS at mount, which must hide the corresponding ad. */
|
|
const visibleBoardAds = () => ONLINE_ONLY_BOARD_ADS.filter((ad) => !(ad.id in BOARD_KIND_LABELS));
|
|
|
|
/** Teal "ONLINE" pill: the board runs (free) in the hosted editor. */
|
|
const OnlineBadge: React.FC = () => (
|
|
<span
|
|
title="Free in the online editor — velxio.com"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 8,
|
|
right: 8,
|
|
zIndex: 1,
|
|
padding: '3px 10px',
|
|
borderRadius: 999,
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
letterSpacing: 0.6,
|
|
color: '#04211c',
|
|
background: 'linear-gradient(180deg,#5eead4,#14b8a6)',
|
|
boxShadow: '0 1px 4px rgba(0,0,0,0.35)',
|
|
}}
|
|
>
|
|
ONLINE
|
|
</span>
|
|
);
|
|
|
|
/** Advertisement card for a component only available in the hosted editor. */
|
|
const OnlineOnlyComponentCard: React.FC<{ ad: OnlineOnlyComponentAd }> = ({ ad }) => (
|
|
<button
|
|
className="component-card"
|
|
style={{ position: 'relative' }}
|
|
title={`${ad.label} — available in the online editor at velxio.com`}
|
|
onClick={() => window.open(ONLINE_EDITOR_URL, '_blank', 'noopener')}
|
|
>
|
|
<OnlineBadge />
|
|
<div className="card-thumbnail">
|
|
<div
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
dangerouslySetInnerHTML={{ __html: ad.thumbnailSvg }}
|
|
/>
|
|
</div>
|
|
<div className="card-content">
|
|
<div className="card-name">{ad.label}</div>
|
|
<div className="card-description">{ad.description}</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
|
|
/** Advertisement card for a board only available in the hosted editor. */
|
|
const OnlineOnlyBoardCard: React.FC<{ ad: OnlineOnlyBoardAd }> = ({ ad }) => (
|
|
<button
|
|
className="component-card"
|
|
style={{ position: 'relative' }}
|
|
title={`${ad.label} — free to use in the online editor at velxio.com`}
|
|
onClick={() => window.open(ONLINE_EDITOR_URL, '_blank', 'noopener')}
|
|
>
|
|
<OnlineBadge />
|
|
<div className="card-thumbnail">
|
|
<div
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
dangerouslySetInnerHTML={{ __html: ad.thumbnailSvg }}
|
|
/>
|
|
</div>
|
|
<div className="card-content">
|
|
<div className="card-name">{ad.label}</div>
|
|
<div className="card-description">{ad.description}</div>
|
|
</div>
|
|
</button>
|
|
);
|