Minor refactor

This commit is contained in:
2026-03-03 22:55:58 +01:00
parent 94f9a9f2ed
commit d641229f9d
4 changed files with 334 additions and 283 deletions

View File

@@ -34,6 +34,7 @@ import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { ChatMarkdownService } from './services/chat-markdown.service';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@@ -79,6 +80,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
private markdown = inject(ChatMarkdownService);
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
remarkProcessor = unified()
@@ -277,7 +279,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return;
const content = this.appendImageMarkdown(raw);
const content = this.markdown.appendImageMarkdown(raw);
this.store.dispatch(
MessagesActions.sendMessage({
@@ -607,109 +609,58 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
applyInline(token: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
this.messageContent = newText;
const cursor = before.length + token.length + selected.length + token.length;
this.setSelection(cursor, cursor);
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
applyPrefix(prefix: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Insert a markdown heading at the given level around the current selection. */
applyHeading(level: number): void {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'Heading';
const after = this.messageContent.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const newText = `${before}${block}${after}`;
this.messageContent = newText;
const cursor = before.length + block.length;
this.setSelection(cursor, cursor);
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Convert selected lines into a numbered markdown list. */
applyOrderedList(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'item\nitem';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Wrap the selection in a fenced markdown code block. */
applyCodeBlock(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'code';
const after = this.messageContent.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const newText = `${before}${fenced}${after}`;
this.messageContent = newText;
const cursor = before.length + fenced.length;
this.setSelection(cursor, cursor);
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Insert a markdown link around the current selection. */
applyLink(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'link';
const after = this.messageContent.slice(end);
const link = `[${selected}](https://)`;
const newText = `${before}${link}${after}`;
this.messageContent = newText;
const cursorStart = before.length + link.length - 1; // position inside url
this.setSelection(cursorStart - 8, cursorStart - 1);
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Insert a markdown image embed around the current selection. */
applyImage(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'alt';
const after = this.messageContent.slice(end);
const img = `![${selected}](https://)`;
const newText = `${before}${img}${after}`;
this.messageContent = newText;
const cursorStart = before.length + img.length - 1;
this.setSelection(cursorStart - 8, cursorStart - 1);
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Insert a horizontal rule at the cursor position. */
applyHorizontalRule(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const after = this.messageContent.slice(end);
const hr = `\n\n---\n\n`;
const newText = `${before}${hr}${after}`;
this.messageContent = newText;
const cursor = before.length + hr.length;
this.setSelection(cursor, cursor);
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
/** Handle drag-enter to activate the drop zone overlay. */
@@ -902,34 +853,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.pendingFiles = [];
}
// Detect image URLs and append Markdown embeds at the end
private appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
// Skip if already embedded as a Markdown image
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement;

View File

@@ -0,0 +1,133 @@
import { Injectable } from '@angular/core';
export interface SelectionRange {
start: number;
end: number;
}
export interface ComposeResult {
text: string;
selectionStart: number;
selectionEnd: number;
}
@Injectable({ providedIn: 'root' })
export class ChatMarkdownService {
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length;
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'Heading';
const after = content.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const text = `${before}${block}${after}`;
const cursor = before.length + block.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'item\nitem';
const after = content.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'code';
const after = content.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'link';
const after = content.slice(end);
const link = `[${selected}](https://)`;
const text = `${before}${link}${after}`;
const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'alt';
const after = content.slice(end);
const img = `![${selected}](https://)`;
const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1;
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const after = content.slice(end);
const hr = `\n\n---\n\n`;
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
}
}