From a43c15dbf131ff678844ca2095c1fd17e0eb01fa Mon Sep 17 00:00:00 2001 From: Harry McCarty Date: Wed, 8 Apr 2026 22:20:32 -0700 Subject: [PATCH 1/4] init --- package-lock.json | 9 +- src/kicad/board.ts | 29 +-- src/kicad/common.ts | 35 +++- src/kicanvas/elements/common/context-menu.ts | 196 +++++++++++++++++++ src/kicanvas/elements/kc-board/viewer.ts | 41 +++- src/viewers/board/viewer.ts | 54 ++++- 6 files changed, 330 insertions(+), 34 deletions(-) create mode 100644 src/kicanvas/elements/common/context-menu.ts diff --git a/package-lock.json b/package-lock.json index 0743e6a6..78203f78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1498,6 +1498,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2418,6 +2419,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3422,7 +3424,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "5.2.0", @@ -3879,6 +3882,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6485,6 +6489,7 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7064,6 +7069,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7190,6 +7196,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/kicad/board.ts b/src/kicad/board.ts index 080418f9..2d2d2c6a 100644 --- a/src/kicad/board.ts +++ b/src/kicad/board.ts @@ -15,7 +15,8 @@ import { expand_text_vars, EmbeddedFile, StrokeParams, - type HasNetName, + Net, + type HasNetInfo, type HasUniqueID, type HasStrokeParams, } from "./common"; @@ -172,7 +173,7 @@ export class Property { } } -export class LineSegment implements HasUniqueID, HasNetName { +export class LineSegment implements HasUniqueID, HasNetInfo { start: Vec2; end: Vec2; width: number; @@ -221,7 +222,7 @@ export class LineSegment implements HasUniqueID, HasNetName { } } -export class ArcSegment implements HasUniqueID, HasNetName { +export class ArcSegment implements HasUniqueID, HasNetInfo { start: Vec2; mid: Vec2; end: Vec2; @@ -273,7 +274,7 @@ export class ArcSegment implements HasUniqueID, HasNetName { } } -export class Via implements HasUniqueID, HasNetName { +export class Via implements HasUniqueID, HasNetInfo { type: "blind" | "micro" | "through-hole" = "through-hole"; at: At; size: number; @@ -674,24 +675,6 @@ export class StackupLayer { } } -export class Net { - number: number; - name: string; - - constructor(expr: Parseable) { - // (net 2 "+3V3") - Object.assign( - this, - parse_expr( - expr, - P.start("net"), - P.positional("number", T.number), - P.positional("name", T.string), - ), - ); - } -} - export class Dimension implements HasUniqueID { locked = false; type: "aligned" | "leader" | "center" | "orthogonal" | "radial"; @@ -1636,7 +1619,7 @@ export class GrText extends Text { } } -export class Pad implements HasUniqueID, HasNetName { +export class Pad implements HasUniqueID, HasNetInfo { number: string; // I hate this type: "thru_hole" | "smd" | "connect" | "np_thru_hole" = "thru_hole"; shape: "circle" | "rect" | "oval" | "trapezoid" | "roundrect" | "custom"; diff --git a/src/kicad/common.ts b/src/kicad/common.ts index e9cbec30..b059fb5a 100644 --- a/src/kicad/common.ts +++ b/src/kicad/common.ts @@ -447,7 +447,38 @@ export interface HasStrokeParams { get stroke_params(): StrokeParams; } -/** Items which have a netname */ -export interface HasNetName { +export class Net { + number: number; + name: string; + + constructor(expr: Parseable) { + // (net 2 "+3V3") + Object.assign( + this, + parse_expr( + expr, + P.start("net"), + P.positional("number", T.number), + P.positional("name", T.string), + ), + ); + } +} + +/** Items which store info for a single net */ +export interface HasNetInfo { + net: number | Net get netname(): string | undefined; } + +export function isNetInfo(obj: any): obj is HasNetInfo { + return "net" in obj && obj.__lookupGetter__("netname") !== undefined; +} + +export function getNetNumber(item: HasNetInfo): number { + if (typeof item.net === "number") { + return item.net; + } else { + return item.net.number; + } +} diff --git a/src/kicanvas/elements/common/context-menu.ts b/src/kicanvas/elements/common/context-menu.ts new file mode 100644 index 00000000..caa2813f --- /dev/null +++ b/src/kicanvas/elements/common/context-menu.ts @@ -0,0 +1,196 @@ +/* + Copyright (c) 2026 Harrison McCarty. + Published under the standard MIT License. + Full text available at: https://opensource.org/licenses/MIT +*/ + +import { css, html } from "../../../base/web-components"; +import { KCUIElement } from "../../../kc-ui/element"; + +export class KCContextMenuElement extends KCUIElement { + static override styles = [ + ...KCUIElement.styles, + css` + :host { + position: fixed; + z-index: 1000; + display: none; + } + + :host([visible]) { + display: block; + } + + .menu { + background: var(--dropdown-bg); + border-radius: 5px; + overflow: hidden; + min-width: 150px; + max-width: 300px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .menu-item { + display: block; + padding: 0.4em 0.8em; + cursor: pointer; + color: var(--dropdown-fg); + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .menu-item:hover { + background: var(--dropdown-hover-bg); + color: var(--dropdown-hover-fg); + } + `, + ]; + + #items: Map | null = null; + #onSelect: ((value: unknown) => void) | null = null; + #boundHandleOutsideClick: ((e: MouseEvent) => void) | null = null; + #boundHandleDismiss: (() => void) | null = null; + + show( + screenX: number, + screenY: number, + items: Map, + onSelect: (value: T) => void, + ) { + this.#items = items; + this.#onSelect = onSelect as (value: unknown) => void; + + this.style.left = `${screenX}px`; + this.style.top = `${screenY}px`; + + this.setAttribute("visible", ""); + + requestAnimationFrame(() => { + this.updateMenuItems(); + this.adjustPosition(); + this.setupDismissListeners(); + }); + } + + hide() { + this.removeAttribute("visible"); + this.#items = null; + this.#onSelect = null; + this.removeDismissListeners(); + } + + private updateMenuItems() { + const menu = this.shadowRoot?.querySelector(".menu"); + if (!menu) return; + + menu.innerHTML = ""; + this.#items?.forEach((item, name) => { + const menuItem = document.createElement("div"); + menuItem.className = "menu-item"; + // menuItem.dataset["index"] = name; + menuItem.textContent = name; + menu.appendChild(menuItem); + }); + } + + private adjustPosition() { + const rect = this.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let x = parseFloat(this.style.left); + let y = parseFloat(this.style.top); + + if (x + rect.width > viewportWidth) { + x = viewportWidth - rect.width - 10; + } + if (y + rect.height > viewportHeight) { + y = viewportHeight - rect.height - 10; + } + + this.style.left = `${Math.max(10, x)}px`; + this.style.top = `${Math.max(10, y)}px`; + } + + private setupDismissListeners() { + // Dismiss context window on outside click, window resize, or scroll + this.removeDismissListeners(); + + this.#boundHandleOutsideClick = (e: MouseEvent) => { + const rect = this.getBoundingClientRect(); + const isInside = + e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom; + + if (!isInside) { + this.hide(); + } + }; + + this.#boundHandleDismiss = () => this.hide(); + + setTimeout(() => { + if (this.#boundHandleOutsideClick) { + document.addEventListener( + "pointerdown", + this.#boundHandleOutsideClick, + ); + } + + if (this.#boundHandleDismiss) { + window.addEventListener("resize", this.#boundHandleDismiss); + window.addEventListener( + "scroll", + this.#boundHandleDismiss, + true, + ); + } + }, 100); + } + + private removeDismissListeners() { + if (this.#boundHandleOutsideClick) { + document.removeEventListener( + "pointerdown", + this.#boundHandleOutsideClick, + ); + this.#boundHandleOutsideClick = null; + } + + if (this.#boundHandleDismiss) { + window.removeEventListener("resize", this.#boundHandleDismiss); + window.removeEventListener( + "scroll", + this.#boundHandleDismiss, + true, + ); + this.#boundHandleDismiss = null; + } + } + + override initialContentCallback() { + super.initialContentCallback(); + + this.shadowRoot?.addEventListener("click", (e: Event) => { + const target = e.target as HTMLElement; + if (target.classList.contains("menu-item") && this.#items) { + if (this.#onSelect) { + this.#onSelect(this.#items.get(target.textContent ?? "")); + } + + this.hide(); + } + }); + } + + override render() { + return html``; + } +} + +window.customElements.define("kc-context-menu", KCContextMenuElement); + diff --git a/src/kicanvas/elements/kc-board/viewer.ts b/src/kicanvas/elements/kc-board/viewer.ts index d6a2c533..a009a60a 100644 --- a/src/kicanvas/elements/kc-board/viewer.ts +++ b/src/kicanvas/elements/kc-board/viewer.ts @@ -4,20 +4,59 @@ Full text available at: https://opensource.org/licenses/MIT */ +import { html } from "../../../base/web-components"; import { BoardViewer } from "../../../viewers/board/viewer"; import { KCViewerElement } from "../common/viewer"; +import type { KCContextMenuElement } from "../common/context-menu"; + +import "../common/context-menu"; export class KCBoardViewerElement extends KCViewerElement { + #contextMenu: KCContextMenuElement | null = null; + protected override update_theme(): void { this.viewer.theme = this.themeObject.board; } protected override make_viewer(): BoardViewer { - return new BoardViewer( + const viewer = new BoardViewer( this.canvas, !this.disableinteraction, this.themeObject.board, + ); + + viewer.contextMenuCallback = (screenX, screenY, items, onSelect) => { + if (!this.#contextMenu) { + return; + } + + this.#contextMenu.show(screenX, screenY, items, onSelect); + }; + + return viewer; + } + + override render() { + this.canvas = html`` as HTMLCanvasElement; + this.#contextMenu = + html`` as KCContextMenuElement; + + return html` + ${this.canvas} ${this.#contextMenu}`; } } diff --git a/src/viewers/board/viewer.ts b/src/viewers/board/viewer.ts index 6797523f..345cd76b 100644 --- a/src/viewers/board/viewer.ts +++ b/src/viewers/board/viewer.ts @@ -9,21 +9,35 @@ import { is_string } from "../../base/types"; import { Renderer } from "../../graphics"; import { WebGL2Renderer } from "../../graphics/webgl"; import type { BoardTheme } from "../../kicad"; +import * as kicad_common from "../../kicad/common"; import * as board_items from "../../kicad/board"; import { DocumentViewer } from "../base/document-viewer"; import { LayerNames, LayerSet, ViewLayer } from "./layers"; import { BoardPainter } from "./painter"; +export type ContextMenuCallback = ( + screenX: number, + screenY: number, + items: Map, + onSelect: (item: unknown) => void, +) => void; + export class BoardViewer extends DocumentViewer< board_items.KicadPCB, BoardPainter, LayerSet, BoardTheme > { + #contextMenuCallback: ContextMenuCallback | null = null; + get board(): board_items.KicadPCB { return this.document; } + set contextMenuCallback(callback: ContextMenuCallback | null) { + this.#contextMenuCallback = callback; + } + protected override create_renderer(canvas: HTMLCanvasElement): Renderer { const renderer = new WebGL2Renderer(canvas); return renderer; @@ -45,16 +59,42 @@ export class BoardViewer extends DocumentViewer< mouse: Vec2, items: Generator<{ layer: ViewLayer; bbox: BBox }, void, unknown>, ): void { - let selected = null; - - for (const { layer: _, bbox } of items) { - if (bbox.context instanceof board_items.Footprint) { - selected = bbox.context; - break; + const selectableItems = new Map(); + for (const { bbox } of items) { + const item = bbox.context; + if (item instanceof board_items.Footprint) { + selectableItems.set( + `Footprint: ${item.reference}`, + item, + ); + } else if (kicad_common.isNetInfo(item)) { + selectableItems.set( + `Net: ${item.netname}`, + item, + ); + } else { + console.log(item); } } - this.select(selected); + if (selectableItems.size === 0) { + this.select(null); + } else if (selectableItems.size === 1 || !this.#contextMenuCallback) { + this.handleItemClick(selectableItems.values().next().value); + } else { + const { x, y } = this.viewport.camera.world_to_screen(mouse); + this.#contextMenuCallback(x, y, selectableItems, (selected) => { + this.handleItemClick(selected); + }); + } + } + + handleItemClick(item: unknown) { + if (item instanceof board_items.Footprint) { + this.select(item); + } else if (kicad_common.isNetInfo(item)) { + this.highlight_net(kicad_common.getNetNumber(item)); + } } override select(item: board_items.Footprint | string | BBox | null) { From dd8c2c46fcde754a4f450fbc19ff1c2de0a3f63a Mon Sep 17 00:00:00 2001 From: Harry McCarty Date: Wed, 8 Apr 2026 22:22:51 -0700 Subject: [PATCH 2/4] revert lock --- package-lock.json | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78203f78..0743e6a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1498,7 +1498,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2419,7 +2418,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3424,8 +3422,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "5.2.0", @@ -3882,7 +3879,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6489,7 +6485,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7069,7 +7064,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7196,7 +7190,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From a76fcd8a8f1413794272414dba65f5bd5a7bf34e Mon Sep 17 00:00:00 2001 From: Harry McCarty Date: Mon, 13 Apr 2026 12:43:47 -0700 Subject: [PATCH 3/4] applied lint --- src/kicad/common.ts | 2 +- src/kicanvas/elements/common/context-menu.ts | 1 - src/kicanvas/elements/kc-board/viewer.ts | 1 - src/viewers/board/viewer.ts | 12 +++--------- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/kicad/common.ts b/src/kicad/common.ts index b059fb5a..f39af162 100644 --- a/src/kicad/common.ts +++ b/src/kicad/common.ts @@ -467,7 +467,7 @@ export class Net { /** Items which store info for a single net */ export interface HasNetInfo { - net: number | Net + net: number | Net; get netname(): string | undefined; } diff --git a/src/kicanvas/elements/common/context-menu.ts b/src/kicanvas/elements/common/context-menu.ts index caa2813f..1c897a6d 100644 --- a/src/kicanvas/elements/common/context-menu.ts +++ b/src/kicanvas/elements/common/context-menu.ts @@ -193,4 +193,3 @@ export class KCContextMenuElement extends KCUIElement { } window.customElements.define("kc-context-menu", KCContextMenuElement); - diff --git a/src/kicanvas/elements/kc-board/viewer.ts b/src/kicanvas/elements/kc-board/viewer.ts index a009a60a..15e4f4e2 100644 --- a/src/kicanvas/elements/kc-board/viewer.ts +++ b/src/kicanvas/elements/kc-board/viewer.ts @@ -23,7 +23,6 @@ export class KCBoardViewerElement extends KCViewerElement { this.canvas, !this.disableinteraction, this.themeObject.board, - ); viewer.contextMenuCallback = (screenX, screenY, items, onSelect) => { diff --git a/src/viewers/board/viewer.ts b/src/viewers/board/viewer.ts index 345cd76b..43cbe737 100644 --- a/src/viewers/board/viewer.ts +++ b/src/viewers/board/viewer.ts @@ -63,17 +63,11 @@ export class BoardViewer extends DocumentViewer< for (const { bbox } of items) { const item = bbox.context; if (item instanceof board_items.Footprint) { - selectableItems.set( - `Footprint: ${item.reference}`, - item, - ); + selectableItems.set(`Footprint: ${item.reference}`, item); } else if (kicad_common.isNetInfo(item)) { - selectableItems.set( - `Net: ${item.netname}`, - item, - ); + selectableItems.set(`Net: ${item.netname}`, item); } else { - console.log(item); + console.log(item); } } From 9b430962f79e1d1b96c0f35da719c25eeb46b8e6 Mon Sep 17 00:00:00 2001 From: Harry McCarty Date: Mon, 13 Apr 2026 12:47:49 -0700 Subject: [PATCH 4/4] applied lint to new md --- docs/docs/home.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/home.md b/docs/docs/home.md index 0bde2099..2ff5396c 100644 --- a/docs/docs/home.md +++ b/docs/docs/home.md @@ -88,9 +88,11 @@ KiCanvas is open source and published under the permissive MIT license. Please t ## Community projects Projects built on KiCanvas: + - [KiSite](https://github.com/hmcty/kisite): A static site generator for KiCad projects Projects with overlapping functionality: + - [KiRi](https://github.com/leoheck/kiri): Visual diff tool for schematics and layouts - [ecad-viewer](https://github.com/Huaqiu-Electronics/ecad-viewer): Fork of KiCanvas - [KiCAD-PRISM](https://github.com/krishna-swaroop/KiCAD-Prism): Cloud-based KiCad workspace, built on `ecad-viewer`