All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
629 lines
16 KiB
TypeScript
629 lines
16 KiB
TypeScript
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<ContextMenuParams | null>(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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const url = this.params()?.linkURL;
|
|
|
|
if (url) {
|
|
await this.writeTextToClipboard(url);
|
|
}
|
|
}
|
|
|
|
private async copyImage(): Promise<void> {
|
|
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<boolean> {
|
|
const text = this.selectionSnapshot?.selectedText ?? '';
|
|
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
return await this.writeTextToClipboard(text);
|
|
}
|
|
|
|
private async cutSelection(): Promise<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<string | null> {
|
|
if (navigator.clipboard?.readText) {
|
|
try {
|
|
return await navigator.clipboard.readText();
|
|
} catch {}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async copyImageToBrowserClipboard(srcURL: string): Promise<boolean> {
|
|
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);
|
|
}
|
|
}
|