Add file upload from clipboard
This commit is contained in:
@@ -178,6 +178,7 @@
|
||||
(blur)="onInputBlur()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange(); autoResizeTextarea()"
|
||||
(paste)="onPaste($event)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
|
||||
@@ -25,6 +25,26 @@ import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indic
|
||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
||||
|
||||
interface ClipboardFilePayload {
|
||||
data: string;
|
||||
lastModified: number;
|
||||
mime: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface ClipboardElectronApi {
|
||||
readClipboardFiles?: () => Promise<ClipboardFilePayload[]>;
|
||||
}
|
||||
|
||||
type ClipboardWindow = Window & {
|
||||
electronAPI?: ClipboardElectronApi;
|
||||
};
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-composer',
|
||||
standalone: true,
|
||||
@@ -256,7 +276,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
onDragEnter(event: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(event))
|
||||
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
@@ -266,7 +286,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(event))
|
||||
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
@@ -296,17 +316,30 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth = 0;
|
||||
const droppedFiles = this.extractDroppedFiles(event);
|
||||
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
|
||||
|
||||
if (droppedFiles.length === 0) {
|
||||
this.dragActive.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingFiles.push(...droppedFiles);
|
||||
this.toolbarVisible.set(true);
|
||||
this.addPendingFiles(droppedFiles);
|
||||
this.dragActive.set(false);
|
||||
this.emitHeight();
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -375,8 +408,26 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private hasPotentialFilePayload(event: DragEvent): boolean {
|
||||
const dataTransfer = event.dataTransfer;
|
||||
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;
|
||||
@@ -397,14 +448,15 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
const types = dataTransfer.types;
|
||||
|
||||
if (!types || types.length === 0)
|
||||
return true;
|
||||
return treatMissingTypesAsPotentialFile;
|
||||
|
||||
for (const type of types) {
|
||||
if (
|
||||
type === 'Files' ||
|
||||
type === 'application/x-moz-file' ||
|
||||
type === 'public.file-url' ||
|
||||
type === 'text/uri-list'
|
||||
type === 'text/uri-list' ||
|
||||
type === 'x-special/gnome-copied-files'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -413,9 +465,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractDroppedFiles(event: DragEvent): File[] {
|
||||
const droppedFiles: File[] = [];
|
||||
const items = event.dataTransfer?.items ?? null;
|
||||
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
|
||||
const extractedFiles: File[] = [];
|
||||
const items = dataTransfer?.items ?? null;
|
||||
|
||||
if (items && items.length) {
|
||||
for (const item of items) {
|
||||
@@ -423,32 +475,128 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
droppedFiles.push(file);
|
||||
this.pushUniqueFile(extractedFiles, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
const files = dataTransfer?.files;
|
||||
|
||||
if (!files?.length)
|
||||
return droppedFiles;
|
||||
return extractedFiles;
|
||||
|
||||
for (const file of files) {
|
||||
const exists = droppedFiles.some(
|
||||
(existingFile) =>
|
||||
existingFile.name === file.name &&
|
||||
existingFile.size === file.size &&
|
||||
existingFile.type === file.type &&
|
||||
existingFile.lastModified === file.lastModified
|
||||
);
|
||||
this.pushUniqueFile(extractedFiles, file);
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
droppedFiles.push(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 = (window as ClipboardWindow).electronAPI;
|
||||
|
||||
if (!electronApi?.readClipboardFiles)
|
||||
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 droppedFiles;
|
||||
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 {
|
||||
|
||||
@@ -42,32 +42,36 @@
|
||||
</div>
|
||||
|
||||
@if (isEditing()) {
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<div class="mt-1 flex items-start gap-2">
|
||||
<textarea
|
||||
#editTextareaRef
|
||||
rows="1"
|
||||
[(ngModel)]="editContent"
|
||||
(keydown.enter)="saveEdit()"
|
||||
(keydown.enter)="onEditEnter($event)"
|
||||
(keydown.escape)="cancelEdit()"
|
||||
class="flex-1 rounded border border-border bg-secondary px-3 py-1 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
(click)="saveEdit()"
|
||||
class="rounded p-1 text-primary hover:bg-primary/10"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="cancelEdit()"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
(input)="autoResizeEditTextarea()"
|
||||
class="edit-textarea flex-1 rounded border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
></textarea>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
(click)="saveEdit()"
|
||||
class="rounded p-1 text-primary hover:bg-primary/10"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="cancelEdit()"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@if (msg.isDeleted) {
|
||||
|
||||
@@ -179,3 +179,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
min-height: 42px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
transition: height 0.12s ease;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
@@ -122,6 +124,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
}
|
||||
})
|
||||
export class ChatMessageItemComponent {
|
||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentService);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
@@ -169,6 +173,28 @@ export class ChatMessageItemComponent {
|
||||
startEdit(): void {
|
||||
this.editContent = this.message().content;
|
||||
this.isEditing.set(true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.autoResizeEditTextarea();
|
||||
|
||||
const element = this.editTextareaRef?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.focus();
|
||||
element.setSelectionRange(element.value.length, element.value.length);
|
||||
});
|
||||
}
|
||||
|
||||
onEditEnter(event: Event): void {
|
||||
const keyEvent = event as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey)
|
||||
return;
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.saveEdit();
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
@@ -188,6 +214,17 @@ export class ChatMessageItemComponent {
|
||||
this.editContent = '';
|
||||
}
|
||||
|
||||
autoResizeEditTextarea(): void {
|
||||
const element = this.editTextareaRef?.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';
|
||||
}
|
||||
|
||||
toggleEmojiPicker(): void {
|
||||
this.showEmojiPicker.update((current) => !current);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user