Minor refactor
This commit is contained in:
@@ -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 = ``;
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = ``;
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
return append ? content + append : content;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user