feat: Add emoji and alot of other fixes

This commit is contained in:
2026-06-05 05:40:18 +02:00
parent ca069e2f61
commit 6865147e8f
72 changed files with 3885 additions and 413 deletions

View File

@@ -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"

View File

@@ -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();
});
});

View File

@@ -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 {