diff --git a/angular.json b/angular.json index 761a12e..846a8d2 100644 --- a/angular.json +++ b/angular.json @@ -61,7 +61,23 @@ } ], "styles": [ - "src/styles.scss" + "src/styles.scss", + "node_modules/prismjs/themes/prism-okaidia.css" + ], + "scripts": [ + "node_modules/prismjs/prism.js", + "node_modules/prismjs/components/prism-markup.min.js", + "node_modules/prismjs/components/prism-clike.min.js", + "node_modules/prismjs/components/prism-javascript.min.js", + "node_modules/prismjs/components/prism-typescript.min.js", + "node_modules/prismjs/components/prism-css.min.js", + "node_modules/prismjs/components/prism-scss.min.js", + "node_modules/prismjs/components/prism-json.min.js", + "node_modules/prismjs/components/prism-bash.min.js", + "node_modules/prismjs/components/prism-markdown.min.js", + "node_modules/prismjs/components/prism-yaml.min.js", + "node_modules/prismjs/components/prism-python.min.js", + "node_modules/prismjs/components/prism-csharp.min.js" ], "allowedCommonJsDependencies": [ "simple-peer", diff --git a/package.json b/package.json index 8747b2d..6c88579 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "clsx": "^2.1.1", "mermaid": "^11.12.3", "ngx-remark": "^0.2.2", + "prismjs": "^1.30.0", "reflect-metadata": "^0.2.2", "remark": "^15.0.1", "remark-breaks": "^4.0.0", diff --git a/src/app/features/chat/chat-messages/chat-messages.component.html b/src/app/features/chat/chat-messages/chat-messages.component.html index 3f54fbb..a354160 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.html +++ b/src/app/features/chat/chat-messages/chat-messages.component.html @@ -122,10 +122,10 @@ [remarkTemplate]="'code'" let-node > - @if (node.lang === 'mermaid') { + @if (isMermaidCodeBlock(node.lang)) { } @else { -
{{ node.value }}
+
{{ node.value }}
} code[class*='language-'] { + display: block; + background: transparent; + padding: 0; + border-radius: 0; + white-space: pre; + word-break: normal; + } + hr { border: none; border-top: 1px solid hsl(var(--border)); diff --git a/src/app/features/chat/chat-messages/chat-messages.component.ts b/src/app/features/chat/chat-messages/chat-messages.component.ts index 3645688..451d359 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.ts +++ b/src/app/features/chat/chat-messages/chat-messages.component.ts @@ -62,6 +62,33 @@ import remarkParse from 'remark-parse'; import { unified } from 'unified'; import { ChatMarkdownService } from './services/chat-markdown.service'; +interface PrismGlobal { + highlightElement(element: Element): void; +} + +declare global { + interface Window { + Prism?: PrismGlobal; + } +} + +const PRISM_LANGUAGE_ALIASES: Record = { + cs: 'csharp', + html: 'markup', + js: 'javascript', + md: 'markdown', + plain: 'none', + plaintext: 'none', + py: 'python', + sh: 'bash', + shell: 'bash', + svg: 'markup', + text: 'none', + ts: 'typescript', + xml: 'markup', + yml: 'yaml', + zsh: 'bash' +}; const COMMON_EMOJIS = [ '👍', '❤️', @@ -201,6 +228,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro lightboxAttachment = signal(null); imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null); private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null; + private prismHighlightScheduled = false; // Reset scroll state when room/server changes (handles reuse of component on navigation) private onRoomChanged = effect(() => { @@ -277,6 +305,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro this.startInitialScrollWatch(); this.showNewMessagesBar.set(false); this.lastMessageCount = this.messages().length; + this.scheduleCodeHighlight(); } else if (!this.loading()) { // Room has no messages and loading is done this.initialScrollPending = false; @@ -287,6 +316,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro } this.updateScrollPadding(); + this.scheduleCodeHighlight(); } ngOnInit(): void { @@ -795,6 +825,69 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url); } + isMermaidCodeBlock(lang?: string): boolean { + return this.normalizeCodeLanguage(lang) === 'mermaid'; + } + + getCodeBlockClass(lang?: string): string { + return `language-${this.normalizeCodeLanguage(lang)}`; + } + + private scheduleCodeHighlight(): void { + if (this.prismHighlightScheduled) + return; + + this.prismHighlightScheduled = true; + requestAnimationFrame(() => { + this.prismHighlightScheduled = false; + this.highlightRenderedCodeBlocks(); + }); + } + + private highlightRenderedCodeBlocks(): void { + const container = this.messagesContainer?.nativeElement as HTMLElement | undefined; + const prism = window.Prism; + + if (!container || !prism?.highlightElement) + return; + + const blocks = container.querySelectorAll('pre > code[class*="language-"]'); + + for (const block of blocks) { + const signature = this.getCodeBlockSignature(block); + + if (block.dataset['prismSignature'] === signature) + continue; + + try { + prism.highlightElement(block); + } finally { + block.dataset['prismSignature'] = signature; + } + } + } + + private getCodeBlockSignature(block: HTMLElement): string { + const value = `${block.className}:${block.textContent ?? ''}`; + + let hash = 0; + + for (let index = 0; index < value.length; index++) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + + return String(hash); + } + + private normalizeCodeLanguage(lang?: string): string { + const normalized = (lang || '').trim().toLowerCase(); + + if (!normalized) + return 'none'; + + return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized; + } + /** Handle drag-enter to activate the drop zone overlay. */ // Attachments: drag/drop and rendering onDragEnter(evt: DragEvent): void {