From 134a94525e91b495dce187562f746a0e78144bda Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 14 Nov 2025 17:05:13 +0200 Subject: [PATCH] fix(markdown): added toc support --- angular.json | 2 + package-lock.json | 25 ++++++-- package.json | 2 + src/@types/markdown-it-toc-done-right.d.ts | 27 +++++++++ .../markdown/markdown.component.html | 2 +- .../components/markdown/markdown.component.ts | 57 ++++++++++++++++++- 6 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/@types/markdown-it-toc-done-right.d.ts 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 9699293ab..d381f7200 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "osf", - "version": "25.2.0", + "version": "25.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "osf", - "version": "25.2.0", + "version": "25.3.0", "dependencies": { "@angular/animations": "^19.2.0", "@angular/cdk": "^19.2.1", @@ -34,6 +34,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", @@ -8190,14 +8192,12 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "^5", @@ -8208,7 +8208,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, "license": "MIT" }, "node_modules/@types/mime": { @@ -18472,6 +18471,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 30c9c6215..f7a2a8d5f 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,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 b8944388e..37abf2701 100644 --- a/src/app/shared/components/markdown/markdown.component.ts +++ b/src/app/shared/components/markdown/markdown.component.ts @@ -1,8 +1,21 @@ -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 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({ @@ -12,11 +25,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()); @@ -37,6 +54,42 @@ export class MarkdownComponent { .use(markdownItKatex, { output: 'mathml', throwOnError: false, + }) + .use(markdownItAnchor) + .use(markdownItTocDoneRight, { + placeholder: '@\\[toc\\]', + listType: 'ul', }); } + + 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!); + }); + } }