diff --git a/packages/core/src/components/scatter-plot/context-menu.styles.ts b/packages/core/src/components/scatter-plot/context-menu.styles.ts new file mode 100644 index 00000000..f39f7f56 --- /dev/null +++ b/packages/core/src/components/scatter-plot/context-menu.styles.ts @@ -0,0 +1,66 @@ +// packages/core/src/components/scatter-plot/context-menu.styles.ts +import { css } from 'lit'; + +export const contextMenuStyles = css` + :host { + position: absolute; + z-index: var(--z-dropdown, 100); + pointer-events: auto; + } + + .menu { + position: absolute; + background: var(--surface, #fff); + border: var(--border-width, 1px) solid var(--border, #e2e8f0); + border-radius: var(--radius, 6px); + padding: 4px 0; + min-width: 180px; + box-shadow: var(--shadow-lg, 0 8px 30px rgba(0, 0, 0, 0.12)); + font-size: var(--text-base, 12px); + font-family: inherit; + } + + .menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 14px; + color: var(--text-primary, #334155); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + font: inherit; + } + + .menu-item:hover { + background: var(--primary-light, #eef6fb); + color: var(--primary, #00a3e0); + } + + .menu-item[aria-disabled='true'] { + color: var(--text-tertiary, #a0aec0); + cursor: default; + pointer-events: none; + } + + .menu-item .icon { + width: 16px; + text-align: center; + font-size: 13px; + flex-shrink: 0; + } + + .menu-item .shortcut { + margin-left: auto; + font-size: var(--text-xs, 10px); + color: var(--text-secondary, #5b6b7a); + } + + .separator { + height: 1px; + background: var(--border, #e2e8f0); + margin: 4px 0; + } +`; diff --git a/packages/core/src/components/scatter-plot/context-menu.test.ts b/packages/core/src/components/scatter-plot/context-menu.test.ts new file mode 100644 index 00000000..c51dc84e --- /dev/null +++ b/packages/core/src/components/scatter-plot/context-menu.test.ts @@ -0,0 +1,32 @@ +// packages/core/src/components/scatter-plot/context-menu.test.ts +import { describe, it, expect } from 'vitest'; +import { resolveMenuItems } from './context-menu'; + +describe('context-menu', () => { + describe('resolveMenuItems', () => { + it('returns point actions when a point is hit', () => { + const items = resolveMenuItems({ + proteinId: 'P0DM09', + hasAccession: true, + dataCoords: [10, 20], + }); + const types = items.map((i) => i.action.type); + expect(types).toContain('copy-id'); + expect(types).toContain('view-uniprot'); + }); + + it('disables view-uniprot when no accession', () => { + const items = resolveMenuItems({ + proteinId: 'custom_001', + hasAccession: false, + dataCoords: [10, 20], + }); + const uniprotItem = items.find((i) => i.action.type === 'view-uniprot'); + expect(uniprotItem?.disabled).toBe(true); + }); + + it('returns no items when no point is hit', () => { + expect(resolveMenuItems(null)).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/components/scatter-plot/context-menu.ts b/packages/core/src/components/scatter-plot/context-menu.ts new file mode 100644 index 00000000..20e19f29 --- /dev/null +++ b/packages/core/src/components/scatter-plot/context-menu.ts @@ -0,0 +1,124 @@ +// packages/core/src/components/scatter-plot/context-menu.ts +import { LitElement, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { contextMenuStyles } from './context-menu.styles'; + +export interface ContextMenuAction { + type: 'copy-id' | 'view-uniprot'; + proteinId: string; +} + +export interface MenuItem { + label: string; + icon: string; + action: ContextMenuAction; + shortcut?: string; + disabled?: boolean; + separator?: boolean; +} + +interface PointHit { + proteinId: string; + hasAccession: boolean; + dataCoords: [number, number]; +} + +export function resolveMenuItems(hit: PointHit | null): MenuItem[] { + if (!hit) return []; + return [ + { + label: 'Copy ID', + icon: '📋', + action: { type: 'copy-id', proteinId: hit.proteinId }, + }, + { + label: 'View in UniProt', + icon: '🔗', + action: { type: 'view-uniprot', proteinId: hit.proteinId }, + disabled: !hit.hasAccession, + }, + ]; +} + +@customElement('protspace-context-menu') +class ProtspaceContextMenu extends LitElement { + static styles = contextMenuStyles; + + @property({ type: Boolean }) open = false; + @property({ type: Array }) items: MenuItem[] = []; + + // Use composedPath() to correctly detect clicks inside nested shadow DOMs. + // With nested shadow DOM, e.target at the document level is retargeted to + // the outermost shadow host, so this.contains(e.target) always returns false. + private _onClickOutside = (e: MouseEvent) => { + if (!e.composedPath().includes(this)) { + this._close(); + } + }; + + private _onEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this._close(); + } + }; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener('mousedown', this._onClickOutside); + document.addEventListener('keydown', this._onEscape); + } + + disconnectedCallback() { + document.removeEventListener('mousedown', this._onClickOutside); + document.removeEventListener('keydown', this._onEscape); + super.disconnectedCallback(); + } + + private _close() { + this.open = false; + this.dispatchEvent(new CustomEvent('context-menu-close')); + } + + private _handleItemClick(item: MenuItem) { + if (item.disabled) return; + this.dispatchEvent( + new CustomEvent('context-menu-action', { + detail: item.action, + bubbles: true, + composed: true, + }), + ); + this._close(); + } + + render() { + if (!this.open) return nothing; + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'protspace-context-menu': ProtspaceContextMenu; + } +} diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index aedcb63a..c7f1da08 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -18,6 +18,8 @@ import { scatterplotStyles } from './scatter-plot.styles'; import './projection-metadata'; import './protspace-tips'; import './protein-tooltip'; +import './context-menu'; +import { resolveMenuItems, type MenuItem, type ContextMenuAction } from './context-menu'; import { DEFAULT_CONFIG } from './config'; import { createStyleGetters } from './style-getters'; import { MAX_POINTS_DIRECT_RENDER, WebGLRenderer } from './webgl'; @@ -76,6 +78,10 @@ export class ProtspaceScatterplot extends LitElement { @property({ type: Boolean, attribute: 'show-tour-button' }) showTourButton = false; // State + @state() private _contextMenuOpen = false; + @state() private _contextMenuX = 0; + @state() private _contextMenuY = 0; + @state() private _contextMenuItems: MenuItem[] = []; @state() private _plotData: PlotDataPoint[] = []; @state() private _tooltipData: { x: number; @@ -2037,12 +2043,66 @@ export class ProtspaceScatterplot extends LitElement { return `left: ${left}px; top: ${top}px;${transform ? ` transform: ${transform};` : ''}`; } + private _hitTestAtClient( + clientX: number, + clientY: number, + ): { proteinId: string; dataCoords: [number, number] } | null { + if (!this._svg || !this._scales) return null; + const rect = this._svg.getBoundingClientRect(); + const localX = clientX - rect.left; + const localY = clientY - rect.top; + const dataX = (localX - this._transform.x) / this._transform.k; + const dataY = (localY - this._transform.y) / this._transform.k; + const searchRadius = 15 / this._transform.k; + const nearest = this._quadtreeIndex.findNearest(dataX, dataY, searchRadius); + if (!nearest) return null; + if (this._getOpacity(nearest) === 0) return null; + if (this._webglRenderer && !this._webglRenderer.isPointRendered(nearest.id)) return null; + return { proteinId: nearest.id, dataCoords: [nearest.x, nearest.y] }; + } + + private _handleContextMenu(e: MouseEvent) { + const hit = this._hitTestAtClient(e.clientX, e.clientY); + if (!hit) { + this._contextMenuOpen = false; + return; + } + e.preventDefault(); + const hasAccession = /^[A-Z][0-9][A-Z0-9]{3}[0-9]|^[A-Z]{2}_\d+/.test(hit.proteinId); + this._contextMenuItems = resolveMenuItems({ ...hit, hasAccession }); + const rect = this.getBoundingClientRect(); + this._contextMenuX = e.clientX - rect.left; + this._contextMenuY = e.clientY - rect.top; + this._contextMenuOpen = true; + } + + private _handleContextMenuAction(e: CustomEvent) { + e.stopPropagation(); + const { type, proteinId } = e.detail; + if (type === 'copy-id') { + void navigator.clipboard?.writeText(proteinId); + } else if (type === 'view-uniprot') { + window.open(`https://www.uniprot.org/uniprotkb/${proteinId}/entry`, '_blank', 'noopener'); + } + this.dispatchEvent( + new CustomEvent('context-menu-action', { + detail: e.detail, + bubbles: true, + composed: true, + }), + ); + } + + private _handleContextMenuClose() { + this._contextMenuOpen = false; + } + render() { const config = this._mergedConfig; const useAltCanvas = this._canvasKey % 2 === 1; return html` -
+
${useAltCanvas ? html` ` : ''} +
`; }