import { Component, OnInit, OnDestroy, HostListener, inject, signal } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ViewportService } from '../../../core/platform/viewport.service'; import { ContextMenuComponent } from '../../../shared'; import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models'; type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll'; type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage'; type TextControlElement = HTMLInputElement | HTMLTextAreaElement; type ContextMenuTarget = TextControlElement | HTMLElement; interface ContextMenuSelectionSnapshot { range: Range | null; selectedText: string; selectionDirection: 'forward' | 'backward' | 'none' | null; selectionEnd: number | null; selectionStart: number | null; target: ContextMenuTarget | null; } const NON_TEXT_INPUT_TYPES = new Set([ 'button', 'checkbox', 'color', 'date', 'datetime-local', 'file', 'hidden', 'image', 'month', 'number', 'radio', 'range', 'reset', 'submit', 'time', 'week' ]); @Component({ selector: 'app-native-context-menu', standalone: true, imports: [ContextMenuComponent], templateUrl: './native-context-menu.component.html' }) export class NativeContextMenuComponent implements OnInit, OnDestroy { params = signal(null); private readonly document = inject(DOCUMENT); private readonly electronBridge = inject(ElectronBridgeService); private readonly viewport = inject(ViewportService); private cleanup: (() => void) | null = null; private selectionSnapshot: ContextMenuSelectionSnapshot | null = null; @HostListener('document:contextmenu', ['$event']) onDocumentContextMenu(event: MouseEvent): void { // On mobile (non-Electron), let the OS-native context menu handle text inputs, // selection, links, and images. Intercepting here suppresses the OS menu and // leaves the user without copy/paste/select-all affordances. if (this.viewport.isMobile() && !this.electronBridge.isAvailable) { return; } this.captureSelectionSnapshot(event); if (this.electronBridge.isAvailable) { return; } const params = this.buildBrowserContextMenuParams(event); if (!params) { this.close(); return; } event.preventDefault(); this.params.set(params); } ngOnInit(): void { const api = this.electronBridge.getApi(); if (!api?.onContextMenu) { return; } this.cleanup = api.onContextMenu((incoming) => { const hasContent = incoming.isEditable || !!incoming.selectionText || !!incoming.linkURL || (incoming.mediaType === 'image' && !!incoming.srcURL); if (!hasContent) { this.close(); return; } this.params.set(incoming); }); } ngOnDestroy(): void { this.cleanup?.(); this.cleanup = null; } close(): void { this.params.set(null); this.selectionSnapshot = null; } onActionPointerDown(event: PointerEvent, action: ContextMenuAction): void { if (event.button !== 0) { return; } event.preventDefault(); void this.runAction(action); } onActionClick(event: MouseEvent, action: ContextMenuAction): void { if (event.detail > 0) { return; } event.preventDefault(); void this.runAction(action); } private async runAction(action: ContextMenuAction): Promise { try { switch (action) { case 'copyLink': await this.copyLink(); break; case 'copyImage': await this.copyImage(); break; default: await this.execCommand(action); break; } } finally { this.close(); } } private async execCommand(command: ContextMenuCommand): Promise { let handled = false; switch (command) { case 'copy': handled = await this.copySelection(); break; case 'cut': handled = await this.cutSelection(); break; case 'paste': handled = await this.pasteSelection(); break; case 'selectAll': handled = this.selectAllSelection(); break; } if (handled) { return; } const api = this.electronBridge.getApi(); if (api?.contextMenuCommand) { this.restoreSelectionSnapshot(); await api.contextMenuCommand(command); } } private async copyLink(): Promise { const url = this.params()?.linkURL; if (url) { await this.writeTextToClipboard(url); } } private async copyImage(): Promise { const srcURL = this.params()?.srcURL; const api = this.electronBridge.getApi(); if (!srcURL) { return; } if (api?.copyImageToClipboard) { const copied = await api.copyImageToClipboard(srcURL).catch(() => false); if (copied) { return; } } await this.copyImageToBrowserClipboard(srcURL); } private captureSelectionSnapshot(event: MouseEvent): void { const target = this.getTargetElement(event.target); const textControl = this.resolveTextControlTarget(target); if (textControl) { this.selectionSnapshot = this.createTextControlSnapshot(textControl); return; } const selection = this.document.getSelection(); this.selectionSnapshot = { range: selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null, selectedText: selection?.toString() ?? '', selectionDirection: null, selectionEnd: null, selectionStart: null, target: this.resolveContentEditableTarget(target) ?? (target instanceof HTMLElement ? target : null) }; } private createTextControlSnapshot(target: TextControlElement): ContextMenuSelectionSnapshot { return { range: null, selectedText: this.getTextControlSelection(target), selectionDirection: target.selectionDirection, selectionEnd: target.selectionEnd, selectionStart: target.selectionStart, target }; } private buildBrowserContextMenuParams(event: MouseEvent): ContextMenuParams | null { const target = this.getTargetElement(event.target); const editableTarget = this.resolveEditableTarget(target); const selectionText = this.selectionSnapshot?.selectedText ?? ''; const linkURL = this.resolveLinkUrl(target); const srcURL = this.resolveImageUrl(target); const isEditable = !!editableTarget && !this.isDisabledTarget(editableTarget); if (!isEditable && !selectionText && !linkURL && !srcURL) { return null; } return { posX: event.clientX, posY: event.clientY, isEditable, selectionText, linkURL, mediaType: srcURL ? 'image' : '', srcURL, editFlags: { canCut: !!selectionText && !!editableTarget && this.canWriteToTarget(editableTarget), canCopy: !!selectionText, canPaste: !!editableTarget && this.canWriteToTarget(editableTarget) && this.canReadClipboard(), canSelectAll: !!editableTarget && !this.isDisabledTarget(editableTarget) } }; } private restoreSelectionSnapshot(): ContextMenuTarget | null { const snapshot = this.selectionSnapshot; if (!snapshot?.target || !snapshot.target.isConnected) { return null; } if (this.isTextControl(snapshot.target)) { snapshot.target.focus({ preventScroll: true }); if (snapshot.selectionStart !== null && snapshot.selectionEnd !== null) { snapshot.target.setSelectionRange( snapshot.selectionStart, snapshot.selectionEnd, snapshot.selectionDirection ?? undefined ); } return snapshot.target; } if (this.isContentEditableTarget(snapshot.target)) { snapshot.target.focus({ preventScroll: true }); } const selection = this.document.getSelection(); if (selection && snapshot.range) { try { selection.removeAllRanges(); selection.addRange(snapshot.range); } catch { return snapshot.target; } } return snapshot.target; } private async copySelection(): Promise { const text = this.selectionSnapshot?.selectedText ?? ''; if (!text) { return false; } return await this.writeTextToClipboard(text); } private async cutSelection(): Promise { const target = this.restoreSelectionSnapshot(); if (!target || !this.canWriteToTarget(target)) { return false; } const text = this.selectionSnapshot?.selectedText ?? ''; if (!text || !(await this.writeTextToClipboard(text))) { return false; } if (this.isTextControl(target)) { const selectionStart = target.selectionStart ?? this.selectionSnapshot?.selectionStart ?? 0; const selectionEnd = target.selectionEnd ?? this.selectionSnapshot?.selectionEnd ?? selectionStart; target.setRangeText('', selectionStart, selectionEnd, 'start'); this.dispatchInputEvent(target); this.selectionSnapshot = this.createTextControlSnapshot(target); return true; } const selection = this.document.getSelection(); if (!selection?.rangeCount) { return false; } const range = selection.getRangeAt(0); range.deleteContents(); selection.removeAllRanges(); selection.addRange(range); this.dispatchInputEvent(target); this.selectionSnapshot = { range: range.cloneRange(), selectedText: '', selectionDirection: null, selectionEnd: null, selectionStart: null, target }; return true; } private async pasteSelection(): Promise { const target = this.restoreSelectionSnapshot(); if (!target || !this.canWriteToTarget(target)) { return false; } const text = await this.readClipboardText(); if (text === null) { return false; } if (this.isTextControl(target)) { const selectionStart = target.selectionStart ?? target.value.length; const selectionEnd = target.selectionEnd ?? selectionStart; target.setRangeText(text, selectionStart, selectionEnd, 'end'); this.dispatchInputEvent(target); this.selectionSnapshot = this.createTextControlSnapshot(target); return true; } const selection = this.document.getSelection(); if (!selection) { return false; } if (!selection.rangeCount) { const range = this.document.createRange(); range.selectNodeContents(target); range.collapse(false); selection.addRange(range); } const range = selection.getRangeAt(0); const textNode = this.document.createTextNode(text); range.deleteContents(); range.insertNode(textNode); range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); this.dispatchInputEvent(target); this.selectionSnapshot = { range: range.cloneRange(), selectedText: '', selectionDirection: null, selectionEnd: null, selectionStart: null, target }; return true; } private selectAllSelection(): boolean { const target = this.selectionSnapshot?.target; if (!target || !target.isConnected || this.isDisabledTarget(target)) { return false; } if (this.isTextControl(target)) { target.focus({ preventScroll: true }); target.select(); this.selectionSnapshot = this.createTextControlSnapshot(target); return true; } if (!this.isContentEditableTarget(target)) { return false; } const selection = this.document.getSelection(); if (!selection) { return false; } const range = this.document.createRange(); target.focus({ preventScroll: true }); range.selectNodeContents(target); selection.removeAllRanges(); selection.addRange(range); this.selectionSnapshot = { range: range.cloneRange(), selectedText: selection.toString(), selectionDirection: null, selectionEnd: null, selectionStart: null, target }; return true; } private async writeTextToClipboard(value: string): Promise { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(value); return true; } catch {} } const body = this.document.body; if (!body) { return false; } const textarea = this.document.createElement('textarea'); textarea.value = value; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; body.appendChild(textarea); textarea.select(); try { return this.document.execCommand('copy'); } catch { return false; } finally { body.removeChild(textarea); } } private async readClipboardText(): Promise { if (navigator.clipboard?.readText) { try { return await navigator.clipboard.readText(); } catch {} } return null; } private async copyImageToBrowserClipboard(srcURL: string): Promise { if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') { return false; } try { const response = await fetch(srcURL); if (!response.ok) { return false; } const blob = await response.blob(); if (!blob.type.startsWith('image/')) { return false; } await navigator.clipboard.write([ new ClipboardItem({ [blob.type || 'image/png']: blob }) ]); return true; } catch { return false; } } private dispatchInputEvent(target: ContextMenuTarget): void { target.dispatchEvent(new Event('input', { bubbles: true })); } private getTextControlSelection(target: TextControlElement): string { const selectionStart = target.selectionStart ?? 0; const selectionEnd = target.selectionEnd ?? selectionStart; return target.value.slice(selectionStart, selectionEnd); } private getTargetElement(target: EventTarget | null): Element | null { if (target instanceof Element) { return target; } return target instanceof Node ? target.parentElement : null; } private resolveEditableTarget(target: Element | null): ContextMenuTarget | null { return this.resolveTextControlTarget(target) ?? this.resolveContentEditableTarget(target); } private resolveTextControlTarget(target: Element | null): TextControlElement | null { const textControl = target?.closest('input, textarea'); if (textControl instanceof HTMLTextAreaElement) { return textControl; } if (textControl instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(textControl.type)) { return textControl; } return null; } private resolveContentEditableTarget(target: Element | null): HTMLElement | null { const editable = target?.closest('[contenteditable]:not([contenteditable="false"])'); return editable instanceof HTMLElement && editable.isContentEditable ? editable : null; } private resolveLinkUrl(target: Element | null): string { const link = target?.closest('a[href]'); return link instanceof HTMLAnchorElement ? link.href : ''; } private resolveImageUrl(target: Element | null): string { const imageTarget = target instanceof HTMLImageElement ? target : target?.closest('img'); return imageTarget instanceof HTMLImageElement ? imageTarget.currentSrc || imageTarget.src : ''; } private isTextControl(target: ContextMenuTarget | null): target is TextControlElement { return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; } private isContentEditableTarget(target: ContextMenuTarget | null): target is HTMLElement { return target instanceof HTMLElement && target.isContentEditable; } private isDisabledTarget(target: ContextMenuTarget | null): boolean { return this.isTextControl(target) ? target.disabled : false; } private canWriteToTarget(target: ContextMenuTarget | null): boolean { if (this.isTextControl(target)) { return !target.disabled && !target.readOnly; } return this.isContentEditableTarget(target); } private canReadClipboard(): boolean { return typeof navigator !== 'undefined' && (!!navigator.clipboard?.readText || this.electronBridge.isAvailable); } }