diff --git a/angular.json b/angular.json index 5108d40ce..7c60c3094 100644 --- a/angular.json +++ b/angular.json @@ -29,6 +29,8 @@ "cedar-embeddable-editor", "cedar-artifact-viewer", "markdown-it-video", + "markdown-it-anchor", + "markdown-it-toc-done-right", "ace-builds/src-noconflict/ext-language_tools", "@traptitech/markdown-it-katex", "@citation-js/core", diff --git a/package-lock.json b/package-lock.json index 451780058..ba1f04f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "chart.js": "^4.4.9", "diff": "^8.0.2", "markdown-it": "^14.1.0", + "markdown-it-anchor": "^9.2.0", + "markdown-it-toc-done-right": "^4.2.0", "markdown-it-video": "^0.6.3", "ngx-captcha": "^13.0.0", "ngx-cookie-service": "^19.1.2", @@ -18490,6 +18492,22 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it-anchor": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz", + "integrity": "sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==", + "license": "Unlicense", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/markdown-it-toc-done-right": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-toc-done-right/-/markdown-it-toc-done-right-4.2.0.tgz", + "integrity": "sha512-UB/IbzjWazwTlNAX0pvWNlJS8NKsOQ4syrXZQ/C72j+jirrsjVRT627lCaylrKJFBQWfRsPmIVQie8x38DEhAQ==", + "license": "MIT" + }, "node_modules/markdown-it-video": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/markdown-it-video/-/markdown-it-video-0.6.3.tgz", diff --git a/package.json b/package.json index 81b20effc..c77a86b06 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "chart.js": "^4.4.9", "diff": "^8.0.2", "markdown-it": "^14.1.0", + "markdown-it-anchor": "^9.2.0", + "markdown-it-toc-done-right": "^4.2.0", "markdown-it-video": "^0.6.3", "ngx-captcha": "^13.0.0", "ngx-cookie-service": "^19.1.2", diff --git a/src/@types/markdown-it-toc-done-right.d.ts b/src/@types/markdown-it-toc-done-right.d.ts new file mode 100644 index 000000000..b30608e7d --- /dev/null +++ b/src/@types/markdown-it-toc-done-right.d.ts @@ -0,0 +1,27 @@ +declare module 'markdown-it-toc-done-right' { + import { PluginWithOptions } from 'markdown-it'; + + export interface TocOptions { + placeholder: string; + slugify: (s: string) => string; + uniqueSlugStartIndex: number; + containerClass: string; + containerId: string; + listClass: string; + itemClass: string; + linkClass: string; + level: number | number[]; + listType: 'ol' | 'ul'; + format: (s: string) => string; + callback: (tocCode: string, ast: TocAst) => void; + } + + export interface TocAst { + l: number; + n: string; + c: TocAst[]; + } + + const markdownItTocDoneRight: PluginWithOptions>; + export default markdownItTocDoneRight; +} diff --git a/src/app/shared/components/markdown/markdown.component.html b/src/app/shared/components/markdown/markdown.component.html index 2a28f5f73..5216622ba 100644 --- a/src/app/shared/components/markdown/markdown.component.html +++ b/src/app/shared/components/markdown/markdown.component.html @@ -1 +1 @@ -
+
diff --git a/src/app/shared/components/markdown/markdown.component.ts b/src/app/shared/components/markdown/markdown.component.ts index 648f6e53b..47bf9d3c4 100644 --- a/src/app/shared/components/markdown/markdown.component.ts +++ b/src/app/shared/components/markdown/markdown.component.ts @@ -1,9 +1,22 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input, Signal } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject, + input, + Signal, + ViewChild, +} from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { legacyImgSize } from '@mdit/plugin-img-size'; import markdownItKatex from '@traptitech/markdown-it-katex'; import MarkdownIt from 'markdown-it'; +import markdownItAnchor from 'markdown-it-anchor'; +import markdownItTocDoneRight from 'markdown-it-toc-done-right'; import markdownItVideo from 'markdown-it-video'; @Component({ @@ -13,11 +26,15 @@ import markdownItVideo from 'markdown-it-video'; styleUrl: './markdown.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MarkdownComponent { +export class MarkdownComponent implements AfterViewInit { markdownText = input(''); + @ViewChild('container', { static: false }) containerRef?: ElementRef; + private md: MarkdownIt; private sanitizer = inject(DomSanitizer); + private destroyRef = inject(DestroyRef); + private clickHandler?: (event: MouseEvent) => void; renderedHtml: Signal = computed(() => { const result = this.md.render(this.markdownText()); @@ -39,6 +56,42 @@ export class MarkdownComponent { output: 'mathml', throwOnError: false, }) + .use(markdownItAnchor) + .use(markdownItTocDoneRight, { + placeholder: '@\\[toc\\]', + listType: 'ul', + }) .use(legacyImgSize); } + + ngAfterViewInit(): void { + this.setupClickHandler(); + } + + private setupClickHandler(): void { + if (!this.containerRef?.nativeElement) { + return; + } + + const container = this.containerRef.nativeElement; + + this.clickHandler = (event: MouseEvent) => { + const anchor = (event.target as HTMLElement).closest('a'); + if (!anchor?.hash) { + return; + } + + const targetElement = document.getElementById(anchor.hash.substring(1)); + if (targetElement) { + event.preventDefault(); + targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + container.addEventListener('click', this.clickHandler); + + this.destroyRef.onDestroy(() => { + container.removeEventListener('click', this.clickHandler!); + }); + } }