Basic language highlighting
This commit is contained in:
18
angular.json
18
angular.json
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user