feat: Add emoji and alot of other fixes
This commit is contained in:
@@ -1,3 +1,31 @@
|
||||
@if (customEmojiMenu(); as emojiMenu) {
|
||||
<app-context-menu
|
||||
[x]="emojiMenu.posX"
|
||||
[y]="emojiMenu.posY"
|
||||
[width]="'w-56'"
|
||||
sheetTitle="Emoji"
|
||||
(closed)="close()"
|
||||
>
|
||||
@if (emojiMenu.action === 'add') {
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item"
|
||||
(click)="addCustomEmojiToLibrary()"
|
||||
>
|
||||
Add to emoji library
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item-danger"
|
||||
(click)="removeCustomEmojiFromLibrary()"
|
||||
>
|
||||
Remove from emoji library
|
||||
</button>
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@if (params()) {
|
||||
<app-context-menu
|
||||
[x]="params()!.posX"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { CustomEmojiService } from '../../../domains/custom-emoji';
|
||||
import { NativeContextMenuComponent } from './native-context-menu.component';
|
||||
|
||||
function createDomTarget(action: 'add' | 'remove', emojiId: string): Element {
|
||||
const host = {
|
||||
getAttribute: vi.fn((name: string) => name === 'data-custom-emoji-id' ? emojiId : null)
|
||||
} as unknown as Element;
|
||||
|
||||
return {
|
||||
closest: vi.fn((selector: string) => {
|
||||
if (action === 'remove' && selector.includes('data-custom-emoji-library')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
if (action === 'add' && selector.includes('data-custom-emoji') && !selector.includes('library')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
} as unknown as Element;
|
||||
}
|
||||
|
||||
describe('NativeContextMenuComponent', () => {
|
||||
function createComponent(options?: {
|
||||
findEmoji?: unknown;
|
||||
isEmojiInLibrary?: boolean;
|
||||
}): NativeContextMenuComponent {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
NativeContextMenuComponent,
|
||||
{
|
||||
provide: DOCUMENT,
|
||||
useValue: {
|
||||
getSelection: () => null,
|
||||
createRange: () => ({
|
||||
selectNodeContents: vi.fn(),
|
||||
collapse: vi.fn(),
|
||||
cloneRange: () => ({})
|
||||
}),
|
||||
body: null
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ViewportService,
|
||||
useValue: {
|
||||
isMobile: () => false
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ElectronBridgeService,
|
||||
useValue: {
|
||||
isAvailable: false,
|
||||
getApi: () => null
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: CustomEmojiService,
|
||||
useValue: {
|
||||
findEmoji: vi.fn(() => options?.findEmoji ?? { id: 'party-id' }),
|
||||
isEmojiInLibrary: vi.fn(() => options?.isEmojiInLibrary ?? false),
|
||||
saveEmojiToLibrary: vi.fn(async () => undefined),
|
||||
removeEmojiFromLibrary: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(NativeContextMenuComponent));
|
||||
}
|
||||
|
||||
it('opens the add-to-library menu for marked chat emoji targets', () => {
|
||||
const component = createComponent();
|
||||
const target = createDomTarget('add', 'party-id');
|
||||
|
||||
component.onDocumentContextMenu({
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 12,
|
||||
clientY: 34
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
expect(component.customEmojiMenu()).toEqual({
|
||||
action: 'add',
|
||||
emojiId: 'party-id',
|
||||
posX: 12,
|
||||
posY: 34
|
||||
});
|
||||
|
||||
expect(component.params()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the remove-from-library menu for marked picker emoji targets', () => {
|
||||
const component = createComponent({ isEmojiInLibrary: true });
|
||||
const target = createDomTarget('remove', 'wave-id');
|
||||
|
||||
component.onDocumentContextMenu({
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 8,
|
||||
clientY: 16
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
expect(component.customEmojiMenu()).toEqual({
|
||||
action: 'remove',
|
||||
emojiId: 'wave-id',
|
||||
posX: 8,
|
||||
posY: 16
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open a custom emoji menu when the emoji is already in the library', () => {
|
||||
const component = createComponent({ isEmojiInLibrary: true });
|
||||
const target = createDomTarget('add', 'party-id');
|
||||
|
||||
component.onDocumentContextMenu({
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 8,
|
||||
clientY: 16
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
expect(component.customEmojiMenu()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ import { ElectronBridgeService } from '../../../core/platform/electron/electron-
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { ContextMenuComponent } from '../../../shared';
|
||||
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||
import {
|
||||
CustomEmojiContextMenuTarget,
|
||||
CustomEmojiService,
|
||||
resolveCustomEmojiContextMenuTarget
|
||||
} from '../../../domains/custom-emoji';
|
||||
|
||||
type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll';
|
||||
type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage';
|
||||
@@ -53,8 +58,10 @@ const NON_TEXT_INPUT_TYPES = new Set([
|
||||
})
|
||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
params = signal<ContextMenuParams | null>(null);
|
||||
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private cleanup: (() => void) | null = null;
|
||||
@@ -62,6 +69,17 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
onDocumentContextMenu(event: MouseEvent): void {
|
||||
const customEmojiTarget = resolveCustomEmojiContextMenuTarget(event.target);
|
||||
|
||||
if (customEmojiTarget) {
|
||||
this.openCustomEmojiMenu(event, customEmojiTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -87,6 +105,8 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.document.addEventListener('contextmenu', this.onDocumentContextMenuCapture, true);
|
||||
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.onContextMenu) {
|
||||
@@ -109,15 +129,45 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.document.removeEventListener('contextmenu', this.onDocumentContextMenuCapture, true);
|
||||
this.cleanup?.();
|
||||
this.cleanup = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentContextMenuCapture = (event: MouseEvent): void => {
|
||||
if (resolveCustomEmojiContextMenuTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
close(): void {
|
||||
this.params.set(null);
|
||||
this.customEmojiMenu.set(null);
|
||||
this.selectionSnapshot = null;
|
||||
}
|
||||
|
||||
async addCustomEmojiToLibrary(): Promise<void> {
|
||||
const menu = this.customEmojiMenu();
|
||||
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.customEmoji.saveEmojiToLibrary(menu.emojiId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async removeCustomEmojiFromLibrary(): Promise<void> {
|
||||
const menu = this.customEmojiMenu();
|
||||
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.customEmoji.removeEmojiFromLibrary(menu.emojiId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onActionPointerDown(event: PointerEvent, action: ContextMenuAction): void {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
@@ -136,6 +186,31 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
void this.runAction(action);
|
||||
}
|
||||
|
||||
private openCustomEmojiMenu(event: MouseEvent, target: CustomEmojiContextMenuTarget): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.params.set(null);
|
||||
this.selectionSnapshot = null;
|
||||
|
||||
if (target.action === 'add') {
|
||||
if (!this.customEmoji.findEmoji(target.emojiId) || this.customEmoji.isEmojiInLibrary(target.emojiId)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.action === 'remove' && !this.customEmoji.isEmojiInLibrary(target.emojiId)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.customEmojiMenu.set({
|
||||
...target,
|
||||
posX: event.clientX,
|
||||
posY: event.clientY
|
||||
});
|
||||
}
|
||||
|
||||
private async runAction(action: ContextMenuAction): Promise<void> {
|
||||
try {
|
||||
switch (action) {
|
||||
@@ -554,11 +629,15 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private getTargetElement(target: EventTarget | null): Element | null {
|
||||
if (target instanceof Element) {
|
||||
return target;
|
||||
if (target && typeof (target as Element).closest === 'function') {
|
||||
return target as Element;
|
||||
}
|
||||
|
||||
return target instanceof Node ? target.parentElement : null;
|
||||
const parentElement = (target as Node | null)?.parentElement;
|
||||
|
||||
return parentElement && typeof parentElement.closest === 'function'
|
||||
? parentElement
|
||||
: null;
|
||||
}
|
||||
|
||||
private resolveEditableTarget(target: Element | null): ContextMenuTarget | null {
|
||||
@@ -592,13 +671,17 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private resolveImageUrl(target: Element | null): string {
|
||||
const imageTarget = target instanceof HTMLImageElement
|
||||
? target
|
||||
: target?.closest('img');
|
||||
const imageTarget = this.asImageLikeElement(target) ?? this.asImageLikeElement(target?.closest('img') ?? null);
|
||||
|
||||
return imageTarget instanceof HTMLImageElement
|
||||
? imageTarget.currentSrc || imageTarget.src
|
||||
: '';
|
||||
return imageTarget?.currentSrc || imageTarget?.src || '';
|
||||
}
|
||||
|
||||
private asImageLikeElement(target: Element | null): { currentSrc?: string; src?: string } | null {
|
||||
if (!target || !('src' in target) || typeof target.src !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return target as { currentSrc?: string; src: string };
|
||||
}
|
||||
|
||||
private isTextControl(target: ContextMenuTarget | null): target is TextControlElement {
|
||||
|
||||
Reference in New Issue
Block a user