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` 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..f39af162 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..1c897a6d --- /dev/null +++ b/src/kicanvas/elements/common/context-menu.ts @@ -0,0 +1,195 @@ +/* + 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..15e4f4e2 100644 --- a/src/kicanvas/elements/kc-board/viewer.ts +++ b/src/kicanvas/elements/kc-board/viewer.ts @@ -4,20 +4,58 @@ 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..43cbe737 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,36 @@ 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) {