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 {