ddd test 2

This commit is contained in:
2026-03-23 00:23:56 +01:00
parent fe9c1dd1c0
commit 21c49f315d
129 changed files with 2486 additions and 816 deletions

View File

@@ -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)"
>
&#96;
</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>

View File

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

View File

@@ -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 `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
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);
}
}
}