ddd test 2
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
|
||||
<div #composerRoot>
|
||||
@if (replyTo()) {
|
||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||
<ng-icon
|
||||
name="lucideReply"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-muted-foreground">
|
||||
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
|
||||
</span>
|
||||
<button
|
||||
(click)="clearReply()"
|
||||
class="rounded p-1 hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-typing-indicator />
|
||||
|
||||
@if (toolbarVisible()) {
|
||||
<div
|
||||
class="pointer-events-auto"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(mouseenter)="onToolbarMouseEnter()"
|
||||
(mouseleave)="onToolbarMouseLeave()"
|
||||
>
|
||||
<div
|
||||
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
||||
>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline('**')"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline('*')"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline('~~')"
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline(inlineCodeToken)"
|
||||
>
|
||||
`
|
||||
</button>
|
||||
<span class="mx-1 text-muted-foreground">|</span>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHeading(1)"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHeading(2)"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHeading(3)"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyPrefix('> ')"
|
||||
>
|
||||
Quote
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyPrefix('- ')"
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyOrderedList()"
|
||||
>
|
||||
1. List
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyCodeBlock()"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyLink()"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyImage()"
|
||||
>
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHorizontalRule()"
|
||||
>
|
||||
HR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="border-border p-4">
|
||||
<div
|
||||
class="chat-input-wrapper relative"
|
||||
(mouseenter)="inputHovered.set(true)"
|
||||
(mouseleave)="inputHovered.set(false)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||
@if (klipy.isEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
type="button"
|
||||
(click)="toggleKlipyGifPicker()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||
[class.border-primary]="showKlipyGifPicker()"
|
||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
||||
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.text-primary]="showKlipyGifPicker()"
|
||||
aria-label="Search KLIPY GIFs"
|
||||
title="Search KLIPY GIFs"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">GIF</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
|
||||
aria-label="Send message"
|
||||
title="Send message"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSend"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
#messageInputRef
|
||||
rows="1"
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange(); autoResizeTextarea()"
|
||||
(paste)="onPaste($event)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
placeholder="Type a message..."
|
||||
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-dashed]="dragActive()"
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
[class.pr-16]="!klipy.isEnabled()"
|
||||
[class.pr-40]="klipy.isEnabled()"
|
||||
></textarea>
|
||||
|
||||
@if (dragActive()) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingKlipyGif()) {
|
||||
<div class="mt-2 flex">
|
||||
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
|
||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
||||
<img
|
||||
[src]="getPendingKlipyGifPreviewUrl()"
|
||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
|
||||
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
|
||||
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removePendingKlipyGif()"
|
||||
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingFiles.length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (file of pendingFiles; track file.name) {
|
||||
<div class="group flex items-center gap-2 rounded border border-border bg-secondary/60 px-2 py-1">
|
||||
<div class="max-w-[14rem] truncate text-xs font-medium">{{ file.name }}</div>
|
||||
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
|
||||
<button
|
||||
(click)="removePendingFile(file)"
|
||||
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
.chat-textarea {
|
||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
|
||||
--textarea-collapsed-padding-y: 18px;
|
||||
--textarea-expanded-padding-y: 8px;
|
||||
|
||||
background: var(--textarea-bg);
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
padding-top: var(--textarea-collapsed-padding-y);
|
||||
padding-bottom: var(--textarea-collapsed-padding-y);
|
||||
resize: none;
|
||||
transition:
|
||||
height 0.12s ease,
|
||||
padding 0.12s ease;
|
||||
|
||||
&.chat-textarea-expanded {
|
||||
padding-top: var(--textarea-expanded-padding-y);
|
||||
padding-bottom: var(--textarea-expanded-padding-y);
|
||||
}
|
||||
|
||||
&.ctrl-resize {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.85);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideImage,
|
||||
lucideReply,
|
||||
lucideSend,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-composer',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
TypingIndicatorComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideImage,
|
||||
lucideReply,
|
||||
lucideSend,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-message-composer.component.html',
|
||||
styleUrl: './chat-message-composer.component.scss',
|
||||
host: {
|
||||
'(document:keydown)': 'onDocKeydown($event)',
|
||||
'(document:keyup)': 'onDocKeyup($event)'
|
||||
}
|
||||
})
|
||||
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
||||
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
||||
|
||||
readonly replyTo = input<Message | null>(null);
|
||||
readonly showKlipyGifPicker = input(false);
|
||||
|
||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||
readonly typingStarted = output();
|
||||
readonly replyCleared = output();
|
||||
readonly heightChanged = output<number>();
|
||||
readonly klipyGifPickerToggleRequested = output();
|
||||
|
||||
readonly klipy = inject(KlipyService);
|
||||
private readonly markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
readonly ctrlHeld = signal(false);
|
||||
readonly textareaExpanded = signal(false);
|
||||
|
||||
messageContent = '';
|
||||
pendingFiles: File[] = [];
|
||||
inlineCodeToken = '`';
|
||||
|
||||
private toolbarHovering = false;
|
||||
private dragDepth = 0;
|
||||
private lastTypingSentAt = 0;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.autoResizeTextarea();
|
||||
this.observeHeight();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
||||
return;
|
||||
|
||||
const content = this.buildOutgoingMessageContent(raw);
|
||||
|
||||
this.messageSubmitted.emit({
|
||||
content,
|
||||
pendingFiles: [...this.pendingFiles]
|
||||
});
|
||||
|
||||
this.messageContent = '';
|
||||
this.pendingFiles = [];
|
||||
this.pendingKlipyGif.set(null);
|
||||
this.replyCleared.emit();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.autoResizeTextarea();
|
||||
this.messageInputRef?.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastTypingSentAt > 1000) {
|
||||
this.typingStarted.emit();
|
||||
this.lastTypingSentAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
clearReply(): void {
|
||||
this.replyCleared.emit();
|
||||
}
|
||||
|
||||
onEnter(event: Event): void {
|
||||
const keyEvent = event as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey)
|
||||
return;
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
applyInline(token: string): void {
|
||||
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyPrefix(prefix: string): void {
|
||||
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyHeading(level: number): void {
|
||||
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyOrderedList(): void {
|
||||
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyCodeBlock(): void {
|
||||
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyLink(): void {
|
||||
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyImage(): void {
|
||||
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyHorizontalRule(): void {
|
||||
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
toggleKlipyGifPicker(): void {
|
||||
if (!this.klipy.isEnabled())
|
||||
return;
|
||||
|
||||
this.klipyGifPickerToggleRequested.emit();
|
||||
}
|
||||
|
||||
getKlipyTriggerRect(): DOMRect | null {
|
||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
handleKlipyGifSelected(gif: KlipyGif): void {
|
||||
this.pendingKlipyGif.set(gif);
|
||||
|
||||
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
|
||||
this.sendMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
removePendingKlipyGif(): void {
|
||||
this.pendingKlipyGif.set(null);
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
getPendingKlipyGifPreviewUrl(): string {
|
||||
const gif = this.pendingKlipyGif();
|
||||
|
||||
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
const units = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB'
|
||||
];
|
||||
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
removePendingFile(file: File): void {
|
||||
const index = this.pendingFiles.findIndex((pendingFile) => pendingFile === file);
|
||||
|
||||
if (index >= 0) {
|
||||
this.pendingFiles.splice(index, 1);
|
||||
this.emitHeight();
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnter(event: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth++;
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
onDragLeave(event: DragEvent): void {
|
||||
if (!this.dragActive())
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth = Math.max(0, this.dragDepth - 1);
|
||||
|
||||
if (this.dragDepth === 0) {
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth = 0;
|
||||
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
|
||||
|
||||
if (droppedFiles.length === 0) {
|
||||
this.dragActive.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.addPendingFiles(droppedFiles);
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
|
||||
async onPaste(event: ClipboardEvent): Promise<void> {
|
||||
if (!this.hasPotentialFilePayload(event.clipboardData, false))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const pastedFiles = await this.extractPastedFiles(event);
|
||||
|
||||
if (pastedFiles.length === 0)
|
||||
return;
|
||||
|
||||
this.addPendingFiles(pastedFiles);
|
||||
}
|
||||
|
||||
autoResizeTextarea(): void {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.style.height = 'auto';
|
||||
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
|
||||
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
this.syncTextareaExpandedState();
|
||||
}
|
||||
|
||||
onInputFocus(): void {
|
||||
this.toolbarVisible.set(true);
|
||||
}
|
||||
|
||||
onInputBlur(): void {
|
||||
setTimeout(() => {
|
||||
if (!this.toolbarHovering) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
onToolbarMouseEnter(): void {
|
||||
this.toolbarHovering = true;
|
||||
}
|
||||
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
|
||||
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onDocKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') {
|
||||
this.ctrlHeld.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
onDocKeyup(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') {
|
||||
this.ctrlHeld.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
return {
|
||||
start: element?.selectionStart ?? this.messageContent.length,
|
||||
end: element?.selectionEnd ?? this.messageContent.length
|
||||
};
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (element) {
|
||||
element.selectionStart = start;
|
||||
element.selectionEnd = end;
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private addPendingFiles(files: File[]): void {
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
const mergedFiles = this.mergeUniqueFiles(this.pendingFiles, files);
|
||||
|
||||
if (mergedFiles.length === this.pendingFiles.length)
|
||||
return;
|
||||
|
||||
this.pendingFiles = mergedFiles;
|
||||
this.toolbarVisible.set(true);
|
||||
this.emitHeight();
|
||||
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
private hasPotentialFilePayload(
|
||||
dataTransfer: DataTransfer | null,
|
||||
treatMissingTypesAsPotentialFile = true
|
||||
): boolean {
|
||||
|
||||
if (!dataTransfer)
|
||||
return false;
|
||||
|
||||
if (dataTransfer.files?.length)
|
||||
return true;
|
||||
|
||||
const items = dataTransfer.items;
|
||||
|
||||
if (items?.length) {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const types = dataTransfer.types;
|
||||
|
||||
if (!types || types.length === 0)
|
||||
return treatMissingTypesAsPotentialFile;
|
||||
|
||||
for (const type of types) {
|
||||
if (
|
||||
type === 'Files' ||
|
||||
type === 'application/x-moz-file' ||
|
||||
type === 'public.file-url' ||
|
||||
type === 'text/uri-list' ||
|
||||
type === 'x-special/gnome-copied-files'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
|
||||
const extractedFiles: File[] = [];
|
||||
const items = dataTransfer?.items ?? null;
|
||||
|
||||
if (items && items.length) {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
this.pushUniqueFile(extractedFiles, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = dataTransfer?.files;
|
||||
|
||||
if (!files?.length)
|
||||
return extractedFiles;
|
||||
|
||||
for (const file of files) {
|
||||
this.pushUniqueFile(extractedFiles, file);
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
}
|
||||
|
||||
private mergeUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] {
|
||||
const mergedFiles = [...existingFiles];
|
||||
|
||||
for (const file of incomingFiles) {
|
||||
this.pushUniqueFile(mergedFiles, file);
|
||||
}
|
||||
|
||||
return mergedFiles;
|
||||
}
|
||||
|
||||
private pushUniqueFile(target: File[], candidate: File): void {
|
||||
const exists = target.some((file) => this.areFilesEquivalent(file, candidate));
|
||||
|
||||
if (!exists) {
|
||||
target.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private areFilesEquivalent(left: File, right: File): boolean {
|
||||
const leftPath = this.getLocalFilePath(left);
|
||||
const rightPath = this.getLocalFilePath(right);
|
||||
|
||||
if (leftPath && rightPath) {
|
||||
return leftPath === rightPath;
|
||||
}
|
||||
|
||||
if (left.name !== right.name || left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const leftType = left.type.trim();
|
||||
const rightType = right.type.trim();
|
||||
|
||||
if (leftType && rightType && leftType !== rightType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const leftLastModified = Number.isFinite(left.lastModified) ? left.lastModified : 0;
|
||||
const rightLastModified = Number.isFinite(right.lastModified) ? right.lastModified : 0;
|
||||
|
||||
if (!leftLastModified || !rightLastModified) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Math.abs(leftLastModified - rightLastModified) <= 1000;
|
||||
}
|
||||
|
||||
private getLocalFilePath(file: File): string {
|
||||
return ((file as LocalFileWithPath).path || '').trim();
|
||||
}
|
||||
|
||||
private async extractPastedFiles(event: ClipboardEvent): Promise<File[]> {
|
||||
const directFiles = this.extractFilesFromTransfer(event.clipboardData);
|
||||
|
||||
if (directFiles.length > 0)
|
||||
return directFiles;
|
||||
|
||||
return await this.readFilesFromElectronClipboard();
|
||||
}
|
||||
|
||||
private async readFilesFromElectronClipboard(): Promise<File[]> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi)
|
||||
return [];
|
||||
|
||||
try {
|
||||
const clipboardFiles = await electronApi.readClipboardFiles();
|
||||
|
||||
return clipboardFiles.map((clipboardFile) => this.createFileFromClipboardPayload(clipboardFile));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private createFileFromClipboardPayload(payload: ClipboardFilePayload): File {
|
||||
const file = new File([this.base64ToArrayBuffer(payload.data)], payload.name, {
|
||||
lastModified: payload.lastModified,
|
||||
type: payload.mime
|
||||
});
|
||||
|
||||
if (payload.path) {
|
||||
try {
|
||||
Object.defineProperty(file, 'path', {
|
||||
configurable: true,
|
||||
value: payload.path
|
||||
});
|
||||
} catch {
|
||||
(file as LocalFileWithPath).path = payload.path;
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
|
||||
for (let index = 0; index < binaryString.length; index++) {
|
||||
bytes[index] = binaryString.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
private buildOutgoingMessageContent(raw: string): string {
|
||||
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
|
||||
const gif = this.pendingKlipyGif();
|
||||
|
||||
if (!gif)
|
||||
return withEmbeddedImages;
|
||||
|
||||
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
|
||||
|
||||
return withEmbeddedImages ? `${withEmbeddedImages}\n${gifMarkdown}` : gifMarkdown;
|
||||
}
|
||||
|
||||
private buildKlipyGifMarkdown(gif: KlipyGif): string {
|
||||
return `})`;
|
||||
}
|
||||
|
||||
private observeHeight(): void {
|
||||
const root = this.composerRoot?.nativeElement;
|
||||
|
||||
if (!root)
|
||||
return;
|
||||
|
||||
this.syncTextareaExpandedState();
|
||||
this.emitHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.syncTextareaExpandedState();
|
||||
this.emitHeight();
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(root);
|
||||
}
|
||||
|
||||
private syncTextareaExpandedState(): void {
|
||||
const textarea = this.messageInputRef?.nativeElement;
|
||||
|
||||
this.textareaExpanded.set(Boolean(textarea && textarea.offsetHeight > DEFAULT_TEXTAREA_HEIGHT));
|
||||
}
|
||||
|
||||
private emitHeight(): void {
|
||||
const root = this.composerRoot?.nativeElement;
|
||||
|
||||
if (root) {
|
||||
this.heightChanged.emit(root.offsetHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user