Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
29 changes: 6 additions & 23 deletions src/kicad/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
expand_text_vars,
EmbeddedFile,
StrokeParams,
type HasNetName,
Net,
type HasNetInfo,
type HasUniqueID,
type HasStrokeParams,
} from "./common";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
35 changes: 33 additions & 2 deletions src/kicad/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
195 changes: 195 additions & 0 deletions src/kicanvas/elements/common/context-menu.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null = null;
#onSelect: ((value: unknown) => void) | null = null;
#boundHandleOutsideClick: ((e: MouseEvent) => void) | null = null;
#boundHandleDismiss: (() => void) | null = null;

show<T>(
screenX: number,
screenY: number,
items: Map<string, unknown>,
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`<div class="menu"></div>`;
}
}

window.customElements.define("kc-context-menu", KCContextMenuElement);
40 changes: 39 additions & 1 deletion src/kicanvas/elements/kc-board/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BoardViewer> {
#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`<canvas></canvas>` as HTMLCanvasElement;
this.#contextMenu =
html`<kc-context-menu></kc-context-menu>` as KCContextMenuElement;

return html`<style>
:host {
display: block;
touch-action: none;
width: 100%;
height: 100%;
position: relative;
}

canvas {
width: 100%;
height: 100%;
}
</style>
${this.canvas} ${this.#contextMenu}`;
}
}

Expand Down
Loading
Loading