Skip to content

Commit 2890714

Browse files
authored
Added click-to-highlight nets/footprints in board viewer (#185)
* init * revert lock * applied lint * applied lint to new md
1 parent 0ea9ae9 commit 2890714

6 files changed

Lines changed: 316 additions & 33 deletions

File tree

‎docs/docs/home.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ KiCanvas is open source and published under the permissive MIT license. Please t
8888
## Community projects
8989

9090
Projects built on KiCanvas:
91+
9192
- [KiSite](https://github.com/hmcty/kisite): A static site generator for KiCad projects
9293

9394
Projects with overlapping functionality:
95+
9496
- [KiRi](https://github.com/leoheck/kiri): Visual diff tool for schematics and layouts
9597
- [ecad-viewer](https://github.com/Huaqiu-Electronics/ecad-viewer): Fork of KiCanvas
9698
- [KiCAD-PRISM](https://github.com/krishna-swaroop/KiCAD-Prism): Cloud-based KiCad workspace, built on `ecad-viewer`

‎src/kicad/board.ts‎

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
expand_text_vars,
1616
EmbeddedFile,
1717
StrokeParams,
18-
type HasNetName,
18+
Net,
19+
type HasNetInfo,
1920
type HasUniqueID,
2021
type HasStrokeParams,
2122
} from "./common";
@@ -172,7 +173,7 @@ export class Property {
172173
}
173174
}
174175

175-
export class LineSegment implements HasUniqueID, HasNetName {
176+
export class LineSegment implements HasUniqueID, HasNetInfo {
176177
start: Vec2;
177178
end: Vec2;
178179
width: number;
@@ -221,7 +222,7 @@ export class LineSegment implements HasUniqueID, HasNetName {
221222
}
222223
}
223224

224-
export class ArcSegment implements HasUniqueID, HasNetName {
225+
export class ArcSegment implements HasUniqueID, HasNetInfo {
225226
start: Vec2;
226227
mid: Vec2;
227228
end: Vec2;
@@ -273,7 +274,7 @@ export class ArcSegment implements HasUniqueID, HasNetName {
273274
}
274275
}
275276

276-
export class Via implements HasUniqueID, HasNetName {
277+
export class Via implements HasUniqueID, HasNetInfo {
277278
type: "blind" | "micro" | "through-hole" = "through-hole";
278279
at: At;
279280
size: number;
@@ -674,24 +675,6 @@ export class StackupLayer {
674675
}
675676
}
676677

677-
export class Net {
678-
number: number;
679-
name: string;
680-
681-
constructor(expr: Parseable) {
682-
// (net 2 "+3V3")
683-
Object.assign(
684-
this,
685-
parse_expr(
686-
expr,
687-
P.start("net"),
688-
P.positional("number", T.number),
689-
P.positional("name", T.string),
690-
),
691-
);
692-
}
693-
}
694-
695678
export class Dimension implements HasUniqueID {
696679
locked = false;
697680
type: "aligned" | "leader" | "center" | "orthogonal" | "radial";
@@ -1636,7 +1619,7 @@ export class GrText extends Text {
16361619
}
16371620
}
16381621

1639-
export class Pad implements HasUniqueID, HasNetName {
1622+
export class Pad implements HasUniqueID, HasNetInfo {
16401623
number: string; // I hate this
16411624
type: "thru_hole" | "smd" | "connect" | "np_thru_hole" = "thru_hole";
16421625
shape: "circle" | "rect" | "oval" | "trapezoid" | "roundrect" | "custom";

‎src/kicad/common.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,38 @@ export interface HasStrokeParams {
447447
get stroke_params(): StrokeParams;
448448
}
449449

450-
/** Items which have a netname */
451-
export interface HasNetName {
450+
export class Net {
451+
number: number;
452+
name: string;
453+
454+
constructor(expr: Parseable) {
455+
// (net 2 "+3V3")
456+
Object.assign(
457+
this,
458+
parse_expr(
459+
expr,
460+
P.start("net"),
461+
P.positional("number", T.number),
462+
P.positional("name", T.string),
463+
),
464+
);
465+
}
466+
}
467+
468+
/** Items which store info for a single net */
469+
export interface HasNetInfo {
470+
net: number | Net;
452471
get netname(): string | undefined;
453472
}
473+
474+
export function isNetInfo(obj: any): obj is HasNetInfo {
475+
return "net" in obj && obj.__lookupGetter__("netname") !== undefined;
476+
}
477+
478+
export function getNetNumber(item: HasNetInfo): number {
479+
if (typeof item.net === "number") {
480+
return item.net;
481+
} else {
482+
return item.net.number;
483+
}
484+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
Copyright (c) 2026 Harrison McCarty.
3+
Published under the standard MIT License.
4+
Full text available at: https://opensource.org/licenses/MIT
5+
*/
6+
7+
import { css, html } from "../../../base/web-components";
8+
import { KCUIElement } from "../../../kc-ui/element";
9+
10+
export class KCContextMenuElement extends KCUIElement {
11+
static override styles = [
12+
...KCUIElement.styles,
13+
css`
14+
:host {
15+
position: fixed;
16+
z-index: 1000;
17+
display: none;
18+
}
19+
20+
:host([visible]) {
21+
display: block;
22+
}
23+
24+
.menu {
25+
background: var(--dropdown-bg);
26+
border-radius: 5px;
27+
overflow: hidden;
28+
min-width: 150px;
29+
max-width: 300px;
30+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
31+
}
32+
33+
.menu-item {
34+
display: block;
35+
padding: 0.4em 0.8em;
36+
cursor: pointer;
37+
color: var(--dropdown-fg);
38+
user-select: none;
39+
white-space: nowrap;
40+
overflow: hidden;
41+
text-overflow: ellipsis;
42+
}
43+
44+
.menu-item:hover {
45+
background: var(--dropdown-hover-bg);
46+
color: var(--dropdown-hover-fg);
47+
}
48+
`,
49+
];
50+
51+
#items: Map<string, unknown> | null = null;
52+
#onSelect: ((value: unknown) => void) | null = null;
53+
#boundHandleOutsideClick: ((e: MouseEvent) => void) | null = null;
54+
#boundHandleDismiss: (() => void) | null = null;
55+
56+
show<T>(
57+
screenX: number,
58+
screenY: number,
59+
items: Map<string, unknown>,
60+
onSelect: (value: T) => void,
61+
) {
62+
this.#items = items;
63+
this.#onSelect = onSelect as (value: unknown) => void;
64+
65+
this.style.left = `${screenX}px`;
66+
this.style.top = `${screenY}px`;
67+
68+
this.setAttribute("visible", "");
69+
70+
requestAnimationFrame(() => {
71+
this.updateMenuItems();
72+
this.adjustPosition();
73+
this.setupDismissListeners();
74+
});
75+
}
76+
77+
hide() {
78+
this.removeAttribute("visible");
79+
this.#items = null;
80+
this.#onSelect = null;
81+
this.removeDismissListeners();
82+
}
83+
84+
private updateMenuItems() {
85+
const menu = this.shadowRoot?.querySelector(".menu");
86+
if (!menu) return;
87+
88+
menu.innerHTML = "";
89+
this.#items?.forEach((item, name) => {
90+
const menuItem = document.createElement("div");
91+
menuItem.className = "menu-item";
92+
// menuItem.dataset["index"] = name;
93+
menuItem.textContent = name;
94+
menu.appendChild(menuItem);
95+
});
96+
}
97+
98+
private adjustPosition() {
99+
const rect = this.getBoundingClientRect();
100+
const viewportWidth = window.innerWidth;
101+
const viewportHeight = window.innerHeight;
102+
103+
let x = parseFloat(this.style.left);
104+
let y = parseFloat(this.style.top);
105+
106+
if (x + rect.width > viewportWidth) {
107+
x = viewportWidth - rect.width - 10;
108+
}
109+
if (y + rect.height > viewportHeight) {
110+
y = viewportHeight - rect.height - 10;
111+
}
112+
113+
this.style.left = `${Math.max(10, x)}px`;
114+
this.style.top = `${Math.max(10, y)}px`;
115+
}
116+
117+
private setupDismissListeners() {
118+
// Dismiss context window on outside click, window resize, or scroll
119+
this.removeDismissListeners();
120+
121+
this.#boundHandleOutsideClick = (e: MouseEvent) => {
122+
const rect = this.getBoundingClientRect();
123+
const isInside =
124+
e.clientX >= rect.left &&
125+
e.clientX <= rect.right &&
126+
e.clientY >= rect.top &&
127+
e.clientY <= rect.bottom;
128+
129+
if (!isInside) {
130+
this.hide();
131+
}
132+
};
133+
134+
this.#boundHandleDismiss = () => this.hide();
135+
136+
setTimeout(() => {
137+
if (this.#boundHandleOutsideClick) {
138+
document.addEventListener(
139+
"pointerdown",
140+
this.#boundHandleOutsideClick,
141+
);
142+
}
143+
144+
if (this.#boundHandleDismiss) {
145+
window.addEventListener("resize", this.#boundHandleDismiss);
146+
window.addEventListener(
147+
"scroll",
148+
this.#boundHandleDismiss,
149+
true,
150+
);
151+
}
152+
}, 100);
153+
}
154+
155+
private removeDismissListeners() {
156+
if (this.#boundHandleOutsideClick) {
157+
document.removeEventListener(
158+
"pointerdown",
159+
this.#boundHandleOutsideClick,
160+
);
161+
this.#boundHandleOutsideClick = null;
162+
}
163+
164+
if (this.#boundHandleDismiss) {
165+
window.removeEventListener("resize", this.#boundHandleDismiss);
166+
window.removeEventListener(
167+
"scroll",
168+
this.#boundHandleDismiss,
169+
true,
170+
);
171+
this.#boundHandleDismiss = null;
172+
}
173+
}
174+
175+
override initialContentCallback() {
176+
super.initialContentCallback();
177+
178+
this.shadowRoot?.addEventListener("click", (e: Event) => {
179+
const target = e.target as HTMLElement;
180+
if (target.classList.contains("menu-item") && this.#items) {
181+
if (this.#onSelect) {
182+
this.#onSelect(this.#items.get(target.textContent ?? ""));
183+
}
184+
185+
this.hide();
186+
}
187+
});
188+
}
189+
190+
override render() {
191+
return html`<div class="menu"></div>`;
192+
}
193+
}
194+
195+
window.customElements.define("kc-context-menu", KCContextMenuElement);

‎src/kicanvas/elements/kc-board/viewer.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,58 @@
44
Full text available at: https://opensource.org/licenses/MIT
55
*/
66

7+
import { html } from "../../../base/web-components";
78
import { BoardViewer } from "../../../viewers/board/viewer";
89
import { KCViewerElement } from "../common/viewer";
10+
import type { KCContextMenuElement } from "../common/context-menu";
11+
12+
import "../common/context-menu";
913

1014
export class KCBoardViewerElement extends KCViewerElement<BoardViewer> {
15+
#contextMenu: KCContextMenuElement | null = null;
16+
1117
protected override update_theme(): void {
1218
this.viewer.theme = this.themeObject.board;
1319
}
1420

1521
protected override make_viewer(): BoardViewer {
16-
return new BoardViewer(
22+
const viewer = new BoardViewer(
1723
this.canvas,
1824
!this.disableinteraction,
1925
this.themeObject.board,
2026
);
27+
28+
viewer.contextMenuCallback = (screenX, screenY, items, onSelect) => {
29+
if (!this.#contextMenu) {
30+
return;
31+
}
32+
33+
this.#contextMenu.show(screenX, screenY, items, onSelect);
34+
};
35+
36+
return viewer;
37+
}
38+
39+
override render() {
40+
this.canvas = html`<canvas></canvas>` as HTMLCanvasElement;
41+
this.#contextMenu =
42+
html`<kc-context-menu></kc-context-menu>` as KCContextMenuElement;
43+
44+
return html`<style>
45+
:host {
46+
display: block;
47+
touch-action: none;
48+
width: 100%;
49+
height: 100%;
50+
position: relative;
51+
}
52+
53+
canvas {
54+
width: 100%;
55+
height: 100%;
56+
}
57+
</style>
58+
${this.canvas} ${this.#contextMenu}`;
2159
}
2260
}
2361

0 commit comments

Comments
 (0)