Basic language highlighting

This commit is contained in:
2026-03-07 17:25:30 +01:00
parent 5013db5e16
commit 901df84d13
5 changed files with 137 additions and 5 deletions

View File

@@ -61,7 +61,23 @@
} }
], ],
"styles": [ "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": [ "allowedCommonJsDependencies": [
"simple-peer", "simple-peer",

View File

@@ -63,6 +63,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"ngx-remark": "^0.2.2", "ngx-remark": "^0.2.2",
"prismjs": "^1.30.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",

View File

@@ -122,10 +122,10 @@
[remarkTemplate]="'code'" [remarkTemplate]="'code'"
let-node let-node
> >
@if (node.lang === 'mermaid') { @if (isMermaidCodeBlock(node.lang)) {
<remark-mermaid [code]="node.value" /> <remark-mermaid [code]="node.value" />
} @else { } @else {
<pre><code>{{ node.value }}</code></pre> <pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
} }
</ng-template> </ng-template>
<ng-template <ng-template

View File

@@ -139,7 +139,7 @@
border-radius: 0 var(--radius) var(--radius) 0; border-radius: 0 var(--radius) var(--radius) 0;
} }
code { code:not([class*='language-']) {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace; font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em; font-size: 0.875em;
background: hsl(var(--secondary)); background: hsl(var(--secondary));
@@ -158,7 +158,7 @@
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
code { code:not([class*='language-']) {
background: transparent; background: transparent;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
@@ -167,6 +167,28 @@
} }
} }
pre[class*='language-'],
code[class*='language-'] {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
text-shadow: none;
}
pre[class*='language-'] {
padding: 0.875em 1rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
}
pre[class*='language-'] > code[class*='language-'] {
display: block;
background: transparent;
padding: 0;
border-radius: 0;
white-space: pre;
word-break: normal;
}
hr { hr {
border: none; border: none;
border-top: 1px solid hsl(var(--border)); border-top: 1px solid hsl(var(--border));

View File

@@ -62,6 +62,33 @@ import remarkParse from 'remark-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { ChatMarkdownService } from './services/chat-markdown.service'; import { ChatMarkdownService } from './services/chat-markdown.service';
interface PrismGlobal {
highlightElement(element: Element): void;
}
declare global {
interface Window {
Prism?: PrismGlobal;
}
}
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
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 = [ const COMMON_EMOJIS = [
'👍', '👍',
'❤️', '❤️',
@@ -201,6 +228,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
lightboxAttachment = signal<Attachment | null>(null); lightboxAttachment = signal<Attachment | null>(null);
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null); imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
private boundOnKeydown: ((event: KeyboardEvent) => void) | 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) // Reset scroll state when room/server changes (handles reuse of component on navigation)
private onRoomChanged = effect(() => { private onRoomChanged = effect(() => {
@@ -277,6 +305,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.startInitialScrollWatch(); this.startInitialScrollWatch();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length; this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight();
} else if (!this.loading()) { } else if (!this.loading()) {
// Room has no messages and loading is done // Room has no messages and loading is done
this.initialScrollPending = false; this.initialScrollPending = false;
@@ -287,6 +316,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
this.updateScrollPadding(); this.updateScrollPadding();
this.scheduleCodeHighlight();
} }
ngOnInit(): void { ngOnInit(): void {
@@ -795,6 +825,69 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url); 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<HTMLElement>('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. */ /** Handle drag-enter to activate the drop zone overlay. */
// Attachments: drag/drop and rendering // Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void { onDragEnter(evt: DragEvent): void {