Add file upload from clipboard
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
|
clipboard,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
@@ -7,6 +8,8 @@ import {
|
|||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as fsp from 'fs/promises';
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { getDesktopSettingsSnapshot, updateDesktopSettings } from '../desktop-settings';
|
import { getDesktopSettingsSnapshot, updateDesktopSettings } from '../desktop-settings';
|
||||||
import {
|
import {
|
||||||
activateLinuxScreenShareAudioRouting,
|
activateLinuxScreenShareAudioRouting,
|
||||||
@@ -16,6 +19,166 @@ import {
|
|||||||
stopLinuxScreenShareMonitorCapture
|
stopLinuxScreenShareMonitorCapture
|
||||||
} from '../audio/linux-screen-share-routing';
|
} from '../audio/linux-screen-share-routing';
|
||||||
|
|
||||||
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
|
'x-special/gnome-copied-files',
|
||||||
|
'text/uri-list',
|
||||||
|
'public.file-url',
|
||||||
|
'FileNameW'
|
||||||
|
] as const;
|
||||||
|
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
||||||
|
'.7z': 'application/x-7z-compressed',
|
||||||
|
'.aac': 'audio/aac',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.gz': 'application/gzip',
|
||||||
|
'.heic': 'image/heic',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.ppt': 'application/vnd.ms-powerpoint',
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'.rar': 'application/vnd.rar',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.tar': 'application/x-tar',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.xls': 'application/vnd.ms-excel',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.zip': 'application/zip'
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClipboardFilePayload {
|
||||||
|
data: string;
|
||||||
|
lastModified: number;
|
||||||
|
mime: string;
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||||
|
return FILE_CLIPBOARD_FORMATS.some(
|
||||||
|
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClipboardFilePaths(buffer: Buffer, format: string): string[] {
|
||||||
|
const textVariants = new Set<string>();
|
||||||
|
const utf8Text = buffer.toString('utf8').replace(/\0/g, '').trim();
|
||||||
|
|
||||||
|
if (utf8Text) {
|
||||||
|
textVariants.add(utf8Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format.toLowerCase() === 'filenamew') {
|
||||||
|
const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n').trim();
|
||||||
|
|
||||||
|
if (utf16Text) {
|
||||||
|
textVariants.add(utf16Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const text of textVariants) {
|
||||||
|
for (const filePath of parseClipboardFilePathText(text)) {
|
||||||
|
filePaths.add(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...filePaths];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClipboardFilePathText(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(
|
||||||
|
(line) =>
|
||||||
|
!!line &&
|
||||||
|
!line.startsWith('#') &&
|
||||||
|
line.toLowerCase() !== 'copy' &&
|
||||||
|
line.toLowerCase() !== 'cut'
|
||||||
|
)
|
||||||
|
.flatMap((line) => {
|
||||||
|
if (line.startsWith('file://')) {
|
||||||
|
try {
|
||||||
|
return [fileURLToPath(line)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.isAbsolute(line) ? [line] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMimeType(filePath: string): string {
|
||||||
|
return MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? DEFAULT_MIME_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
||||||
|
const formats = clipboard.availableFormats();
|
||||||
|
const filePaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
if (!isSupportedClipboardFileFormat(format)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = clipboard.readBuffer(format);
|
||||||
|
|
||||||
|
for (const filePath of extractClipboardFilePaths(buffer, format)) {
|
||||||
|
filePaths.add(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore unreadable clipboard formats */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: ClipboardFilePayload[] = [];
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fsp.readFile(filePath);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
data: data.toString('base64'),
|
||||||
|
lastModified: Math.round(stats.mtimeMs),
|
||||||
|
mime: inferMimeType(filePath),
|
||||||
|
name: path.basename(filePath),
|
||||||
|
path: filePath
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore files that are no longer readable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupSystemHandlers(): void {
|
export function setupSystemHandlers(): void {
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||||
@@ -88,6 +251,10 @@ export function setupSystemHandlers(): void {
|
|||||||
return data.toString('base64');
|
return data.toString('base64');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('read-clipboard-files', async () => {
|
||||||
|
return await readClipboardFiles();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
|
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export interface LinuxScreenShareMonitorAudioEndedPayload {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClipboardFilePayload {
|
||||||
|
data: string;
|
||||||
|
lastModified: number;
|
||||||
|
mime: string;
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
@@ -58,6 +66,7 @@ export interface ElectronAPI {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||||
@@ -114,6 +123,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||||
|
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||||
|
|||||||
@@ -178,6 +178,7 @@
|
|||||||
(blur)="onInputBlur()"
|
(blur)="onInputBlur()"
|
||||||
(keydown.enter)="onEnter($event)"
|
(keydown.enter)="onEnter($event)"
|
||||||
(input)="onInputChange(); autoResizeTextarea()"
|
(input)="onInputChange(); autoResizeTextarea()"
|
||||||
|
(paste)="onPaste($event)"
|
||||||
(dragenter)="onDragEnter($event)"
|
(dragenter)="onDragEnter($event)"
|
||||||
(dragover)="onDragOver($event)"
|
(dragover)="onDragOver($event)"
|
||||||
(dragleave)="onDragLeave($event)"
|
(dragleave)="onDragLeave($event)"
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indic
|
|||||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||||
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
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({
|
@Component({
|
||||||
selector: 'app-chat-message-composer',
|
selector: 'app-chat-message-composer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -256,7 +276,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDragEnter(event: DragEvent): void {
|
onDragEnter(event: DragEvent): void {
|
||||||
if (!this.hasPotentialFilePayload(event))
|
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -266,7 +286,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDragOver(event: DragEvent): void {
|
onDragOver(event: DragEvent): void {
|
||||||
if (!this.hasPotentialFilePayload(event))
|
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -296,17 +316,30 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.dragDepth = 0;
|
this.dragDepth = 0;
|
||||||
const droppedFiles = this.extractDroppedFiles(event);
|
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
|
||||||
|
|
||||||
if (droppedFiles.length === 0) {
|
if (droppedFiles.length === 0) {
|
||||||
this.dragActive.set(false);
|
this.dragActive.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingFiles.push(...droppedFiles);
|
this.addPendingFiles(droppedFiles);
|
||||||
this.toolbarVisible.set(true);
|
|
||||||
this.dragActive.set(false);
|
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 {
|
autoResizeTextarea(): void {
|
||||||
@@ -375,8 +408,26 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasPotentialFilePayload(event: DragEvent): boolean {
|
private addPendingFiles(files: File[]): void {
|
||||||
const dataTransfer = event.dataTransfer;
|
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)
|
if (!dataTransfer)
|
||||||
return false;
|
return false;
|
||||||
@@ -397,14 +448,15 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
const types = dataTransfer.types;
|
const types = dataTransfer.types;
|
||||||
|
|
||||||
if (!types || types.length === 0)
|
if (!types || types.length === 0)
|
||||||
return true;
|
return treatMissingTypesAsPotentialFile;
|
||||||
|
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
if (
|
if (
|
||||||
type === 'Files' ||
|
type === 'Files' ||
|
||||||
type === 'application/x-moz-file' ||
|
type === 'application/x-moz-file' ||
|
||||||
type === 'public.file-url' ||
|
type === 'public.file-url' ||
|
||||||
type === 'text/uri-list'
|
type === 'text/uri-list' ||
|
||||||
|
type === 'x-special/gnome-copied-files'
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -413,9 +465,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractDroppedFiles(event: DragEvent): File[] {
|
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
|
||||||
const droppedFiles: File[] = [];
|
const extractedFiles: File[] = [];
|
||||||
const items = event.dataTransfer?.items ?? null;
|
const items = dataTransfer?.items ?? null;
|
||||||
|
|
||||||
if (items && items.length) {
|
if (items && items.length) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -423,32 +475,128 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
droppedFiles.push(file);
|
this.pushUniqueFile(extractedFiles, file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = event.dataTransfer?.files;
|
const files = dataTransfer?.files;
|
||||||
|
|
||||||
if (!files?.length)
|
if (!files?.length)
|
||||||
return droppedFiles;
|
return extractedFiles;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const exists = droppedFiles.some(
|
this.pushUniqueFile(extractedFiles, file);
|
||||||
(existingFile) =>
|
}
|
||||||
existingFile.name === file.name &&
|
|
||||||
existingFile.size === file.size &&
|
|
||||||
existingFile.type === file.type &&
|
|
||||||
existingFile.lastModified === file.lastModified
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
return extractedFiles;
|
||||||
droppedFiles.push(file);
|
}
|
||||||
|
|
||||||
|
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 {
|
private buildOutgoingMessageContent(raw: string): string {
|
||||||
|
|||||||
@@ -42,32 +42,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isEditing()) {
|
@if (isEditing()) {
|
||||||
<div class="mt-1 flex gap-2">
|
<div class="mt-1 flex items-start gap-2">
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
#editTextareaRef
|
||||||
|
rows="1"
|
||||||
[(ngModel)]="editContent"
|
[(ngModel)]="editContent"
|
||||||
(keydown.enter)="saveEdit()"
|
(keydown.enter)="onEditEnter($event)"
|
||||||
(keydown.escape)="cancelEdit()"
|
(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"
|
(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"
|
||||||
<button
|
></textarea>
|
||||||
(click)="saveEdit()"
|
<div class="flex flex-col gap-2">
|
||||||
class="rounded p-1 text-primary hover:bg-primary/10"
|
<button
|
||||||
>
|
(click)="saveEdit()"
|
||||||
<ng-icon
|
class="rounded p-1 text-primary hover:bg-primary/10"
|
||||||
name="lucideCheck"
|
>
|
||||||
class="h-4 w-4"
|
<ng-icon
|
||||||
/>
|
name="lucideCheck"
|
||||||
</button>
|
class="h-4 w-4"
|
||||||
<button
|
/>
|
||||||
(click)="cancelEdit()"
|
</button>
|
||||||
class="rounded p-1 text-muted-foreground hover:bg-secondary"
|
<button
|
||||||
>
|
(click)="cancelEdit()"
|
||||||
<ng-icon
|
class="rounded p-1 text-muted-foreground hover:bg-secondary"
|
||||||
name="lucideX"
|
>
|
||||||
class="h-4 w-4"
|
<ng-icon
|
||||||
/>
|
name="lucideX"
|
||||||
</button>
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@if (msg.isDeleted) {
|
@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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
ElementRef,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal
|
signal,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
@@ -122,6 +124,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class ChatMessageItemComponent {
|
export class ChatMessageItemComponent {
|
||||||
|
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||||
|
|
||||||
private readonly attachmentsSvc = inject(AttachmentService);
|
private readonly attachmentsSvc = inject(AttachmentService);
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||||
@@ -169,6 +173,28 @@ export class ChatMessageItemComponent {
|
|||||||
startEdit(): void {
|
startEdit(): void {
|
||||||
this.editContent = this.message().content;
|
this.editContent = this.message().content;
|
||||||
this.isEditing.set(true);
|
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 {
|
saveEdit(): void {
|
||||||
@@ -188,6 +214,17 @@ export class ChatMessageItemComponent {
|
|||||||
this.editContent = '';
|
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 {
|
toggleEmojiPicker(): void {
|
||||||
this.showEmojiPicker.update((current) => !current);
|
this.showEmojiPicker.update((current) => !current);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user