Basic language highlighting
This commit is contained in:
18
angular.json
18
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -122,10 +122,10 @@
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (node.lang === 'mermaid') {
|
||||
@if (isMermaidCodeBlock(node.lang)) {
|
||||
<remark-mermaid [code]="node.value" />
|
||||
} @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
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
code {
|
||||
code:not([class*='language-']) {
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--secondary));
|
||||
@@ -158,7 +158,7 @@
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(var(--border));
|
||||
|
||||
code {
|
||||
code:not([class*='language-']) {
|
||||
background: transparent;
|
||||
padding: 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 {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
|
||||
@@ -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<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 = [
|
||||
'👍',
|
||||
'❤️',
|
||||
@@ -201,6 +228,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
lightboxAttachment = signal<Attachment | null>(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<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. */
|
||||
// Attachments: drag/drop and rendering
|
||||
onDragEnter(evt: DragEvent): void {
|
||||
|
||||
Reference in New Issue
Block a user