mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Add topology display pickers for label, edge-number, and vendor icon colors.
Persist choices in localStorage, apply via canvas CSS variables, and tint router icons with mask fills so each vendor color is editable. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4a0918ab44
commit
65afe8f29f
4 changed files with 288 additions and 51 deletions
|
|
@ -1466,7 +1466,25 @@ const en = {
|
|||
display: "Display",
|
||||
canvasBg: "Canvas background",
|
||||
canvasBgColor: "Background color",
|
||||
canvasBgReset: "Reset light blue",
|
||||
canvasBgReset: "Reset default",
|
||||
textColors: "Text colors",
|
||||
textColorsReset: "Reset default",
|
||||
labelColor: "NE name",
|
||||
edgeLabelColor: "Link numbers",
|
||||
vendorColors: "Vendor icon colors",
|
||||
vendorColorsReset: "Reset default",
|
||||
vendorTone: {
|
||||
cisco: "Cisco",
|
||||
huawei: "Huawei",
|
||||
zte: "ZTE",
|
||||
juniper: "Juniper",
|
||||
nokia: "Nokia",
|
||||
ericsson: "Ericsson",
|
||||
h3c: "H3C",
|
||||
ruijie: "Ruijie",
|
||||
mikrotik: "MikroTik",
|
||||
gray: "Unknown / unmanaged",
|
||||
},
|
||||
hideIp: "Hide IP",
|
||||
hideVendor: "Hide vendor",
|
||||
hidePorts: "Hide ports",
|
||||
|
|
|
|||
|
|
@ -1460,7 +1460,25 @@ const zh = {
|
|||
display: "显示",
|
||||
canvasBg: "画布背景",
|
||||
canvasBgColor: "背景色",
|
||||
canvasBgReset: "恢复浅蓝",
|
||||
canvasBgReset: "恢复默认",
|
||||
textColors: "文字颜色",
|
||||
textColorsReset: "恢复默认",
|
||||
labelColor: "网元名称",
|
||||
edgeLabelColor: "链路数字",
|
||||
vendorColors: "厂商图标色",
|
||||
vendorColorsReset: "恢复默认",
|
||||
vendorTone: {
|
||||
cisco: "Cisco",
|
||||
huawei: "华为",
|
||||
zte: "中兴",
|
||||
juniper: "Juniper",
|
||||
nokia: "Nokia",
|
||||
ericsson: "Ericsson",
|
||||
h3c: "H3C",
|
||||
ruijie: "锐捷",
|
||||
mikrotik: "MikroTik",
|
||||
gray: "未知/未托管",
|
||||
},
|
||||
hideIp: "隐藏 IP",
|
||||
hideVendor: "隐藏厂商",
|
||||
hidePorts: "隐藏端口",
|
||||
|
|
|
|||
|
|
@ -6877,7 +6877,7 @@ pre {
|
|||
min-height: 0;
|
||||
background: var(--topo-canvas-bg, #0f172a);
|
||||
--xy-edge-label-background-color: transparent;
|
||||
--xy-edge-label-color: #e2e8f0;
|
||||
--xy-edge-label-color: var(--topo-edge-label-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.topo-canvas:fullscreen,
|
||||
|
|
@ -7023,50 +7023,39 @@ pre {
|
|||
box-shadow: none;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
/* Base asset /topo/ne-router.png is navy-blue; tints approximate brand hues. */
|
||||
--topo-icon-filter: none;
|
||||
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
|
||||
}
|
||||
|
||||
/* Cisco — cyan / light blue (#049FD9) */
|
||||
.topo-node--cisco {
|
||||
--topo-icon-filter: hue-rotate(-18deg) saturate(1.25) brightness(1.08);
|
||||
--topo-icon-color: var(--topo-vendor-cisco, #049fd9);
|
||||
}
|
||||
/* Huawei — red (#CF0A2C); was incorrectly greenish at 155deg */
|
||||
.topo-node--huawei {
|
||||
--topo-icon-filter: hue-rotate(128deg) saturate(1.85) brightness(1.02);
|
||||
--topo-icon-color: var(--topo-vendor-huawei, #cf0a2c);
|
||||
}
|
||||
/* ZTE — keep navy-blue base (#0091DA family) */
|
||||
.topo-node--zte {
|
||||
--topo-icon-filter: saturate(1.15) brightness(1.02);
|
||||
--topo-icon-color: var(--topo-vendor-zte, #0091da);
|
||||
}
|
||||
/* Juniper — green (#84B135) */
|
||||
.topo-node--juniper {
|
||||
--topo-icon-filter: hue-rotate(78deg) saturate(1.35) brightness(1.05);
|
||||
--topo-icon-color: var(--topo-vendor-juniper, #84b135);
|
||||
}
|
||||
/* Nokia / Alcatel — deep blue-purple (#124191) */
|
||||
.topo-node--nokia {
|
||||
--topo-icon-filter: hue-rotate(-35deg) saturate(1.2) brightness(0.92);
|
||||
--topo-icon-color: var(--topo-vendor-nokia, #124191);
|
||||
}
|
||||
/* Ericsson — dark navy */
|
||||
.topo-node--ericsson {
|
||||
--topo-icon-filter: hue-rotate(-8deg) saturate(1.1) brightness(0.82);
|
||||
--topo-icon-color: var(--topo-vendor-ericsson, #1e3a5f);
|
||||
}
|
||||
/* H3C — green (#7AC143) */
|
||||
.topo-node--h3c {
|
||||
--topo-icon-filter: hue-rotate(88deg) saturate(1.45) brightness(1.06);
|
||||
--topo-icon-color: var(--topo-vendor-h3c, #7ac143);
|
||||
}
|
||||
/* Ruijie — purple */
|
||||
.topo-node--ruijie {
|
||||
--topo-icon-filter: hue-rotate(45deg) saturate(1.4) brightness(1.0);
|
||||
--topo-icon-color: var(--topo-vendor-ruijie, #7c3aed);
|
||||
}
|
||||
/* MikroTik — red-orange */
|
||||
.topo-node--mikrotik {
|
||||
--topo-icon-filter: hue-rotate(145deg) saturate(1.7) brightness(1.05);
|
||||
--topo-icon-color: var(--topo-vendor-mikrotik, #ea580c);
|
||||
}
|
||||
/* Unmanaged / unknown vendor */
|
||||
.topo-node--gray,
|
||||
.topo-node--other {
|
||||
--topo-icon-filter: grayscale(1) brightness(0.92) contrast(0.95);
|
||||
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
|
||||
}
|
||||
|
||||
.topo-node__glyph {
|
||||
|
|
@ -7125,16 +7114,15 @@ pre {
|
|||
|
||||
.topo-node.is-selected .topo-node__icon {
|
||||
transform: none;
|
||||
filter: var(--topo-icon-filter, none);
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__caption {
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__caption-name {
|
||||
color: #93c5fd;
|
||||
color: var(--topo-label-color, #f1f5f9);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Selected link: light highlight only (do not alter geometry / arrows). */
|
||||
|
|
@ -7148,11 +7136,18 @@ pre {
|
|||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
filter: var(--topo-icon-filter, none);
|
||||
transition: transform 0.12s ease, filter 0.12s ease;
|
||||
background-color: var(--topo-icon-color, #3b82f6);
|
||||
-webkit-mask-image: url(/topo/ne-router.png);
|
||||
mask-image: url(/topo/ne-router.png);
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
transition: transform 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.topo-node__caption {
|
||||
|
|
@ -7161,7 +7156,7 @@ pre {
|
|||
align-items: center;
|
||||
gap: 1px;
|
||||
margin-top: 3px;
|
||||
color: #e2e8f0;
|
||||
color: var(--topo-label-color, #f1f5f9);
|
||||
line-height: 1.2;
|
||||
max-width: 160px;
|
||||
pointer-events: none;
|
||||
|
|
@ -7175,7 +7170,7 @@ pre {
|
|||
max-width: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
color: var(--topo-label-color, #f1f5f9);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -7185,7 +7180,7 @@ pre {
|
|||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #64748b);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -7198,7 +7193,7 @@ pre {
|
|||
}
|
||||
|
||||
.topo-canvas .react-flow__edge-text {
|
||||
fill: #e2e8f0;
|
||||
fill: var(--topo-edge-label-color, #e2e8f0);
|
||||
font-size: 11px;
|
||||
paint-order: stroke;
|
||||
stroke: rgba(15, 23, 42, 0.85);
|
||||
|
|
@ -7207,7 +7202,7 @@ pre {
|
|||
|
||||
.topo-edge-label {
|
||||
font-size: 11px;
|
||||
color: #e2e8f0;
|
||||
color: var(--topo-edge-label-color, #e2e8f0);
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0;
|
||||
|
|
@ -7220,8 +7215,9 @@ pre {
|
|||
}
|
||||
|
||||
.topo-edge-label.is-selected {
|
||||
color: #93c5fd;
|
||||
color: var(--topo-edge-label-color, #e2e8f0);
|
||||
font-weight: 600;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.topo-ctx__member-list {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -370,17 +371,9 @@ function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
|
|||
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||
}
|
||||
|
||||
/** Asset: /topo/ne-router.png (navy blue base); vendors tint via CSS filters. */
|
||||
/** Silhouette via CSS mask; fill color comes from --topo-icon-color (vendor palette). */
|
||||
function RouterIcon() {
|
||||
return (
|
||||
<img
|
||||
className="topo-node__icon"
|
||||
src="/topo/ne-router.png"
|
||||
alt=""
|
||||
draggable={false}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
return <span className="topo-node__icon" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
|
||||
|
|
@ -455,6 +448,46 @@ const CANVAS_BG_KEY = "netx.topology.canvasBg";
|
|||
const DEFAULT_CANVAS_BG = "#0f172a";
|
||||
/** Previous light default — migrate so existing sessions pick up dark canvas. */
|
||||
const LEGACY_CANVAS_BG = "#dbeafe";
|
||||
const LABEL_COLORS_KEY = "netx.topology.labelColors";
|
||||
const VENDOR_COLORS_KEY = "netx.topology.vendorColors";
|
||||
|
||||
const VENDOR_TONE_KEYS = [
|
||||
"cisco",
|
||||
"huawei",
|
||||
"zte",
|
||||
"juniper",
|
||||
"nokia",
|
||||
"ericsson",
|
||||
"h3c",
|
||||
"ruijie",
|
||||
"mikrotik",
|
||||
"gray",
|
||||
] as const;
|
||||
type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number];
|
||||
type VendorColors = Record<VendorToneKey, string>;
|
||||
type LabelColors = { name: string; edgeLabel: string };
|
||||
|
||||
const DEFAULT_LABEL_COLORS: LabelColors = {
|
||||
name: "#f1f5f9",
|
||||
edgeLabel: "#e2e8f0",
|
||||
};
|
||||
|
||||
const DEFAULT_VENDOR_COLORS: VendorColors = {
|
||||
cisco: "#049fd9",
|
||||
huawei: "#cf0a2c",
|
||||
zte: "#0091da",
|
||||
juniper: "#84b135",
|
||||
nokia: "#124191",
|
||||
ericsson: "#1e3a5f",
|
||||
h3c: "#7ac143",
|
||||
ruijie: "#7c3aed",
|
||||
mikrotik: "#ea580c",
|
||||
gray: "#94a3b8",
|
||||
};
|
||||
|
||||
function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value);
|
||||
}
|
||||
|
||||
function loadCanvasBg(): string {
|
||||
try {
|
||||
|
|
@ -463,7 +496,7 @@ function loadCanvasBg(): string {
|
|||
persistCanvasBg(DEFAULT_CANVAS_BG);
|
||||
return DEFAULT_CANVAS_BG;
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{6}$/i.test(raw)) return raw;
|
||||
if (isHexColor(raw)) return raw;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
@ -478,6 +511,54 @@ function persistCanvasBg(value: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function loadLabelColors(): LabelColors {
|
||||
try {
|
||||
const raw = localStorage.getItem(LABEL_COLORS_KEY);
|
||||
if (!raw) return { ...DEFAULT_LABEL_COLORS };
|
||||
const parsed = JSON.parse(raw) as Partial<LabelColors>;
|
||||
return {
|
||||
name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name,
|
||||
edgeLabel: isHexColor(String(parsed.edgeLabel || ""))
|
||||
? String(parsed.edgeLabel).toLowerCase()
|
||||
: DEFAULT_LABEL_COLORS.edgeLabel,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_LABEL_COLORS };
|
||||
}
|
||||
}
|
||||
|
||||
function persistLabelColors(value: LabelColors) {
|
||||
try {
|
||||
localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function loadVendorColors(): VendorColors {
|
||||
try {
|
||||
const raw = localStorage.getItem(VENDOR_COLORS_KEY);
|
||||
if (!raw) return { ...DEFAULT_VENDOR_COLORS };
|
||||
const parsed = JSON.parse(raw) as Partial<VendorColors>;
|
||||
const out = { ...DEFAULT_VENDOR_COLORS };
|
||||
for (const key of VENDOR_TONE_KEYS) {
|
||||
const c = String(parsed?.[key] || "").trim().toLowerCase();
|
||||
if (isHexColor(c)) out[key] = c;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return { ...DEFAULT_VENDOR_COLORS };
|
||||
}
|
||||
}
|
||||
|
||||
function persistVendorColors(value: VendorColors) {
|
||||
try {
|
||||
localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function canvasDotColor(bg: string): string {
|
||||
const hex = String(bg || "").replace("#", "");
|
||||
if (hex.length !== 6) return "#93c5fd";
|
||||
|
|
@ -741,6 +822,8 @@ export function TopologyPage() {
|
|||
const [edgeFlow, setEdgeFlow] = useState(false);
|
||||
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
||||
const [canvasBg, setCanvasBg] = useState(loadCanvasBg);
|
||||
const [labelColors, setLabelColors] = useState<LabelColors>(() => loadLabelColors());
|
||||
const [vendorColors, setVendorColors] = useState<VendorColors>(() => loadVendorColors());
|
||||
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
||||
const [snapToGrid, setSnapToGrid] = useState(true);
|
||||
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
|
||||
|
|
@ -2900,16 +2983,122 @@ export function TopologyPage() {
|
|||
const raw = e.target.value.trim();
|
||||
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
|
||||
setCanvasBg(raw);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw)) persistCanvasBg(raw.toLowerCase());
|
||||
if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase());
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(canvasBg)) {
|
||||
if (!isHexColor(canvasBg)) {
|
||||
setCanvasBg(loadCanvasBg());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
|
||||
<div className="topo-display-defaults__head">
|
||||
<strong>{t("topology.textColors")}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setLabelColors({ ...DEFAULT_LABEL_COLORS });
|
||||
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
|
||||
}}
|
||||
>
|
||||
{t("topology.textColorsReset")}
|
||||
</button>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
["name", t("topology.labelColor")],
|
||||
["edgeLabel", t("topology.edgeLabelColor")],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="topo-display-defaults__row">
|
||||
<span className="topo-display-defaults__name">{label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={labelColors[key]}
|
||||
title={label}
|
||||
onChange={(e) => {
|
||||
const next = { ...labelColors, [key]: e.target.value.toLowerCase() };
|
||||
setLabelColors(next);
|
||||
persistLabelColors(next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="topo-toolbar__select topo-canvas-bg-hex"
|
||||
value={labelColors[key]}
|
||||
spellCheck={false}
|
||||
aria-label={label}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
|
||||
const next = { ...labelColors, [key]: raw };
|
||||
setLabelColors(next);
|
||||
if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isHexColor(labelColors[key])) {
|
||||
setLabelColors(loadLabelColors());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
|
||||
<div className="topo-display-defaults__head">
|
||||
<strong>{t("topology.vendorColors")}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setVendorColors({ ...DEFAULT_VENDOR_COLORS });
|
||||
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
|
||||
}}
|
||||
>
|
||||
{t("topology.vendorColorsReset")}
|
||||
</button>
|
||||
</div>
|
||||
{VENDOR_TONE_KEYS.map((key) => {
|
||||
const label = t(`topology.vendorTone.${key}`);
|
||||
return (
|
||||
<div key={key} className="topo-display-defaults__row">
|
||||
<span className="topo-display-defaults__name">{label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={vendorColors[key]}
|
||||
title={label}
|
||||
onChange={(e) => {
|
||||
const next = { ...vendorColors, [key]: e.target.value.toLowerCase() };
|
||||
setVendorColors(next);
|
||||
persistVendorColors(next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="topo-toolbar__select topo-canvas-bg-hex"
|
||||
value={vendorColors[key]}
|
||||
spellCheck={false}
|
||||
aria-label={label}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
|
||||
const next = { ...vendorColors, [key]: raw };
|
||||
setVendorColors(next);
|
||||
if (isHexColor(raw)) {
|
||||
persistVendorColors({ ...next, [key]: raw.toLowerCase() });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isHexColor(vendorColors[key])) {
|
||||
setVendorColors(loadVendorColors());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="topo-display-defaults">
|
||||
<div className="topo-display-defaults__head">
|
||||
<strong>{t("topology.edgeDefaults")}</strong>
|
||||
|
|
@ -3327,7 +3516,23 @@ export function TopologyPage() {
|
|||
<div
|
||||
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${toolMode === "pan" ? " is-pan-mode" : ""}`}
|
||||
ref={canvasRef}
|
||||
style={{ ["--topo-canvas-bg" as string]: canvasBg }}
|
||||
style={
|
||||
{
|
||||
"--topo-canvas-bg": canvasBg,
|
||||
"--topo-label-color": labelColors.name,
|
||||
"--topo-edge-label-color": labelColors.edgeLabel,
|
||||
"--topo-vendor-cisco": vendorColors.cisco,
|
||||
"--topo-vendor-huawei": vendorColors.huawei,
|
||||
"--topo-vendor-zte": vendorColors.zte,
|
||||
"--topo-vendor-juniper": vendorColors.juniper,
|
||||
"--topo-vendor-nokia": vendorColors.nokia,
|
||||
"--topo-vendor-ericsson": vendorColors.ericsson,
|
||||
"--topo-vendor-h3c": vendorColors.h3c,
|
||||
"--topo-vendor-ruijie": vendorColors.ruijie,
|
||||
"--topo-vendor-mikrotik": vendorColors.mikrotik,
|
||||
"--topo-vendor-gray": vendorColors.gray,
|
||||
} as CSSProperties
|
||||
}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue