From f582bc15394c455d90d25848a3f4d69e53cc6970 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 2 Apr 2026 14:05:32 +0200 Subject: [PATCH 1/3] fix(chat-messages): improve chat container layout and examples Add max-inline-size constraint to message bubble and wrapper to prevent overflow. Fix attachment preview backdrop click behavior. Use index-based tracking for message list and add scroll-to-bottom on message send. Update example action labels for clarity. --- .../si-attachment-list.component.ts | 3 +- .../si-chat-message.component.scss | 2 + .../si-chat-messages/si-chat-container.html | 4 +- .../si-chat-messages/si-chat-container.ts | 38 ++++++++++--------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.ts b/projects/element-ng/chat-messages/si-attachment-list.component.ts index 75f84ea1ad..6be67e7c3a 100644 --- a/projects/element-ng/chat-messages/si-attachment-list.component.ts +++ b/projects/element-ng/chat-messages/si-attachment-list.component.ts @@ -105,7 +105,8 @@ export class SiAttachmentListComponent { if (template) { event.preventDefault(); this.modalService.show(template, { - inputValues: { 'attachment': attachment } + inputValues: { 'attachment': attachment }, + ignoreBackdropClick: false }); } } diff --git a/projects/element-ng/chat-messages/si-chat-message.component.scss b/projects/element-ng/chat-messages/si-chat-message.component.scss index b2484bba0d..4f34bb8708 100644 --- a/projects/element-ng/chat-messages/si-chat-message.component.scss +++ b/projects/element-ng/chat-messages/si-chat-message.component.scss @@ -35,6 +35,7 @@ margin-block-end: auto; background-color: var(--chat-message-bubble-bg); min-inline-size: 0; + max-inline-size: 100%; overflow-wrap: break-word; word-break: break-word; @@ -50,6 +51,7 @@ .message-wrapper { min-inline-size: 0; + max-inline-size: 100%; } .attachment-slot { diff --git a/src/app/examples/si-chat-messages/si-chat-container.html b/src/app/examples/si-chat-messages/si-chat-container.html index 97bc153641..addeea605d 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.html +++ b/src/app/examples/si-chat-messages/si-chat-container.html @@ -1,6 +1,6 @@
- - @for (message of messages(); track message) { + + @for (message of messages(); track $index) { @if (message.type === 'user') { this.logEvent(`Copy user message ${message.content.slice(0, 20)}...`) @@ -120,14 +120,14 @@ export class SampleComponent { Let me examine the structure and provide guidance.`, actions: [ { - label: 'Good response', + label: 'Add to list', icon: 'element-plus', - action: (_message: ChatMessage) => this.logEvent('Thumbs up for AI message') + action: (_message: ChatMessage) => this.logEvent('Add AI message to list') }, { - label: 'Copy response', + label: 'Export response', icon: 'element-export', - action: (_message: ChatMessage) => this.logEvent('Copy AI message') + action: (_message: ChatMessage) => this.logEvent('Export AI message') }, { label: 'Retry response', @@ -147,7 +147,7 @@ export class SampleComponent { 'Perfect! What should I focus on first\n\nI also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows?', actions: [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', action: (_message: ChatMessage) => this.logEvent(`Copy user message ${_message.content.slice(0, 20)}...`) @@ -178,7 +178,7 @@ export class SampleComponent { userActions: MessageAction[] = [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', action: (_message: ChatMessage) => this.logEvent(`Copy user message ${_message.content.slice(0, 20)}...`) @@ -193,14 +193,14 @@ export class SampleComponent { aiActions: MessageAction[] = [ { - label: 'Good response', + label: 'Add to list', icon: 'element-plus', - action: (_message: ChatMessage) => this.logEvent('Thumbs up for AI message') + action: (_message: ChatMessage) => this.logEvent('Add AI message to list') }, { - label: 'Copy response', + label: 'Export response', icon: 'element-export', - action: (_message: ChatMessage) => this.logEvent('Copy AI message') + action: (_message: ChatMessage) => this.logEvent('Export AI message') } ]; @@ -272,9 +272,9 @@ export class SampleComponent { content: event.content, actions: [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', - action: () => this.logEvent('Copy user message') + action: () => this.logEvent('Export user message') } ], attachments: event.attachments.map(att => ({ @@ -284,6 +284,10 @@ export class SampleComponent { } ]); this.simulateAiResponse(event.content); + + setTimeout(() => { + this.chatContainer()?.scrollToBottom(); + }, 0); } onInterrupt(): void { @@ -314,14 +318,14 @@ export class SampleComponent { content: response, actions: [ { - label: 'Good response', + label: 'Add to list', icon: 'element-plus', - action: () => this.logEvent('Thumbs up for AI message') + action: () => this.logEvent('Add AI message to list') }, { - label: 'Copy response', + label: 'Export response', icon: 'element-export', - action: () => this.logEvent('Copy AI message') + action: () => this.logEvent('Export AI message') } ] } From b4b8aad3c7526e2862afec110b156d00dce9052b Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 2 Apr 2026 14:13:44 +0200 Subject: [PATCH 2/3] refactor(markdown-renderer): restructure renderer with recursive pipeline and caching Rewrite the markdown renderer to use a recursive processing pipeline with placeholder-based architecture. Add SSR support via optional doc/isBrowser parameters. Introduce LRU caching for code blocks and tables. Improve table rendering with proper thead/th support. Add code-wrapper structure with language labels. Update SCSS for new structural layout including code-wrapper, table-wrapper, and table-scroll-container. --- .../element-ng/markdown-renderer/index.api.md | 5 +- ...e-element-examples-chromium-dark-linux.png | 4 +- .../si-chat-messages--si-chat-container.yaml | 8 +- ...e-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- .../si-chat-messages--si-user-message.yaml | 3 +- ...r-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- ...rkdown-renderer--si-markdown-renderer.yaml | 32 +- .../si-ai-message.component.spec.ts | 4 +- .../si-user-message.component.spec.ts | 4 +- .../markdown-renderer-helpers.ts | 43 + .../markdown-renderer/markdown-renderer.ts | 802 +++++++++++++----- .../si-markdown-renderer.component.spec.ts | 63 +- .../si-markdown-renderer.component.ts | 14 +- .../src/styles/components/_markdown.scss | 83 +- .../si-chat-messages/si-ai-message.ts | 12 +- .../si-chat-messages/si-chat-container.ts | 13 +- .../si-chat-messages/si-user-message.ts | 12 +- src/assets/sample-markdown.md | 21 +- 20 files changed, 833 insertions(+), 306 deletions(-) create mode 100644 projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index 089ac799bc..16bb3d213c 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -8,7 +8,10 @@ import { DomSanitizer } from '@angular/platform-browser'; import * as i0 from '@angular/core'; // @public -export const getMarkdownRenderer: (sanitizer: DomSanitizer) => ((text: string) => Node); +export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRendererOptions, doc?: Document, isBrowser?: boolean) => ((text: string) => Node); + +// @public (undocumented) +export type MarkdownRendererOptions = Record; // @public export class SiMarkdownRendererComponent { diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png index 2ee076d320..e5494a03a0 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87bca818ae96eb1f8e85f138e862646fc787a1cace67579b589a282f9dabb866 -size 13930 +oid sha256:b17f626eba110891730376dbd2bedaf259f274d3fbad96cb66c4142bd0845cb6 +size 13834 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml index 80c9bb0810..532dd51083 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml @@ -4,16 +4,16 @@ - button "dataset.csv" - paragraph: Can you help me analyze these files? - paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. -- button "Copy message" +- button "Export message" - paragraph: I'd be happy to help you analyze your files! I can see you've shared a Python script and a CSV dataset. - paragraph: Let me examine the structure and provide guidance. -- button "Good response" -- button "Copy response" +- button "Add to list" +- button "Export response" - button "Retry response" - button "More actions" - paragraph: Perfect! What should I focus on first - paragraph: I also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows? -- button "Copy message" +- button "Export message" - paragraph: Great question! When analyzing large datasets, it's crucial to focus on... - alert: Info AI responses are for demonstration purposes. - group: diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png index b7a085448a..330c34c0eb 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e16910032892fcb3cb90409e190b1eee22f61c90fff8b7c85516a5698b303361 -size 14583 +oid sha256:eb7e8f0f280cb6abedbd22e4ebf33f6e0b7a011cc3d6ef536f46ea8887a0ff2e +size 14584 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png index 5d975069b3..3f3aaffc9e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58dfa48dee58689efefba1c9629fd127a31186d16b693826b1e1f9b42026ca7c -size 14224 +oid sha256:152047c64d8437edea14f76be0dad86f6aa677ead9e816d8d03ee36486399404 +size 14242 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml index a4ba54ee17..08a98b5682 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml @@ -4,8 +4,7 @@ - text: Can you help me with this - strong: code snippet - text: "?" -- paragraph: - - code: console.log('Hello World') +- code: console.log('Hello World') - paragraph: I'm getting an error when I run it. - button "Edit message" - button "Copy message" diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png index 51092e1d98..403db2edc5 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:812619a4f94ad30609fb21818985dd7c84413357890804d08673e6215cbdd499 -size 142176 +oid sha256:97820b06d9c6df325515233fa0daed5ca4ffbaca5f0a758325befe1fdd033645 +size 150623 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png index b0d249bfeb..df12e9e0de 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc434913688b2e13eb056baa60429fe2745bc002d5d1d5c77c6c39888c71f7a6 -size 137998 +oid sha256:04600154322a26e45074b1e0cefc09f66d4ad0a1869982b21e5de5e11005f73d +size 147403 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml index caf1199cb2..264a38aacc 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml @@ -1,5 +1,5 @@ -- heading "AI Assistant Response" [level=2]: - - strong: AI Assistant Response +- heading "Sample Markdown Content" [level=2]: + - strong: Sample Markdown Content - paragraph: - text: Here's a - strong: comprehensive example @@ -10,9 +10,9 @@ - text: You can use inline code like - code: console.log('Hello World') - text: "or multi-line code blocks:" -- paragraph +- text: javascript - code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" -- paragraph +- separator - heading "Formatting Options" [level=2] - paragraph: Here's a paragraph explaining the formatting options available. - paragraph: "Another paragraph with different formatting elements:" @@ -39,6 +39,7 @@ - text: "Links are also automatically detected:" - link "https://angular.io": - /url: https://angular.io +- separator - heading "Lists and Bullets" [level=2] - paragraph: "Here are the key features:" - list: @@ -47,6 +48,8 @@ - listitem: Inline code highlighting - listitem: Bullet point lists - listitem: Blockquote support + - listitem: Or in the alternate format + - listitem: Another bullet point - paragraph: This paragraph appears after the list to show proper spacing. - heading "Ordered Lists" [level=2] - paragraph: "Step-by-step instructions:" @@ -54,7 +57,9 @@ - listitem: First, analyze the requirements - listitem: Then, implement the solution - listitem: Finally, test the implementation -- blockquote: This is a blockquote that demonstrates how quoted text appears in the markdown content component. +- separator +- blockquote: + - paragraph: This is a blockquote that demonstrates how quoted text appears in the markdown content component. - paragraph: This paragraph follows the blockquotes to demonstrate proper paragraph separation. - paragraph: This is a separate paragraph created by double line breaks. - list: @@ -65,20 +70,24 @@ - listitem: First ordered item - listitem: Second ordered item - paragraph: Final paragraph to show proper spacing. +- separator +- heading "Images" [level=2] +- paragraph: "Images can be included as follows:" +- separator - heading "Tables" [level=2] - paragraph: "Tables are also supported:" -- paragraph - table: - rowgroup: - row "Feature Examples Status Notes": - - cell "Feature": + - columnheader "Feature": - paragraph: Feature - - cell "Examples": + - columnheader "Examples": - paragraph: Examples - - cell "Status": + - columnheader "Status": - paragraph: Status - - cell "Notes": + - columnheader "Notes": - paragraph: Notes + - rowgroup: - row "Basic content Alice Johnson Bob Smith ✓ Complete Simple text and line breaks": - cell "Basic content": - paragraph: @@ -139,5 +148,4 @@ - text: Uses - code:
- text: tags -- text: This paragraph appears after the tables to demonstrate proper spacing. -- paragraph \ No newline at end of file +- paragraph: This paragraph appears after the tables to demonstrate proper spacing. \ No newline at end of file diff --git a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts index 82868ac14d..c0bee3cac6 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ +import { DOCUMENT } from '@angular/common'; import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By, DomSanitizer } from '@angular/platform-browser'; @@ -42,7 +43,8 @@ describe('SiAiMessageComponent', () => { }); debugElement = fixture.debugElement; const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + const doc = TestBed.inject(DOCUMENT); + markdownRenderer = getMarkdownRenderer(sanitizer, undefined, doc, true); }); it('should render markdown content', async () => { diff --git a/projects/element-ng/chat-messages/si-user-message.component.spec.ts b/projects/element-ng/chat-messages/si-user-message.component.spec.ts index ca1e94ffe8..597170bc00 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ +import { DOCUMENT } from '@angular/common'; import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By, DomSanitizer } from '@angular/platform-browser'; @@ -43,7 +44,8 @@ describe('SiUserMessageComponent', () => { }); debugElement = fixture.debugElement; const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + const doc = TestBed.inject(DOCUMENT); + markdownRenderer = getMarkdownRenderer(sanitizer, undefined, doc, true); }); it('should render markdown content', async () => { diff --git a/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts new file mode 100644 index 0000000000..da40565b63 --- /dev/null +++ b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ + +/** + * Gets a cached HTML element or creates a new one if not in cache. + * Implements LRU caching strategy. + */ +export const getCachedOrCreateElement = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createHtml: () => string, + doc: Document +): HTMLElement => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const tempDiv = doc.createElement('div'); + tempDiv.innerHTML = createHtml(); + const element = tempDiv.firstElementChild as HTMLElement; + + cache.set(key, element); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return element; +}; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index d7f1022c2d..851cfc9b80 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -5,259 +5,621 @@ import { SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { getCachedOrCreateElement } from './markdown-renderer-helpers'; + +const CACHE_SIZE = 100; + +export type MarkdownRendererOptions = Record; + +interface ProcessOptions { + allowCodeBlocks?: boolean; + allowBlockquotes?: boolean; + allowTables?: boolean; + allowInlineCode?: boolean; + allowLinks?: boolean; +} + /** - * Returns a markdown renderer function which_ - * - Transforms markdown text into formatted HTML. - * - Returns a DOM node containing the formatted content. + * Returns a function that transforms markdown text into a formatted HTML node. * - * **Warning:** The returned Node is inserted without additional sanitization. - * Input content is sanitized before processing. + * **Important for SSR**: When using this function in an SSR context, you must provide the `doc` and `isBrowser` parameters. + * Call this within an Angular injection context and pass `inject(DOCUMENT)` and `isPlatformBrowser(inject(PLATFORM_ID))`. * * @experimental * @param sanitizer - Angular DomSanitizer instance + * @param options - Optional configuration for the markdown renderer + * @param doc - Document instance (optional for browser-only apps, required for SSR - pass inject(DOCUMENT)) + * @param isBrowser - Whether running in browser (optional for browser-only apps, required for SSR - pass isPlatformBrowser(inject(PLATFORM_ID))) * @returns A function taking the markdown text to transform and returning a DOM div element containing the formatted HTML */ -export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => Node) => { - return (text: string): Node => { - const div = document.createElement('div'); - div.className = 'markdown-content text-break'; +export const getMarkdownRenderer = ( + sanitizer: DomSanitizer, + options?: MarkdownRendererOptions, + doc?: Document, + isBrowser?: boolean +): ((text: string) => Node) => { + // Use provided document or fall back to global document for backwards compatibility + const docRef = doc ?? document; + const isInBrowser = isBrowser ?? true; + + // Persistent caches within this renderer instance + const codeBlockCache = new Map(); + const tableCache = new Map(); + const codeBlockCacheOrder: string[] = []; + const tableCacheOrder: string[] = []; + + // Placeholder maps for cached elements + const codeBlockPlaceholderMap = new Map(); + const tablePlaceholderMap = new Map(); + + // Placeholder maps for inline elements + const linkPlaceholderMap = new Map(); + + /** + * Main recursive processing function + */ + const processMarkdown = ( + text: string, + processOpts: ProcessOptions = { + allowCodeBlocks: true, + allowBlockquotes: true, + allowTables: true, + allowInlineCode: true, + allowLinks: true + } + ): string => { + let result = text; + + // Step 1: Extract and process code blocks (4+ backticks for nested markdown) + if (processOpts.allowCodeBlocks) { + const codeBlockMap = new Map(); + + // Match code blocks with 4 or more backticks (for displaying nested code blocks) + result = result.replace( + /(^|\n)([\s]*)(````+)([^\n]*)\n?([\s\S]*?)\n?\s*\3/gm, + (match, prefix, indent, backticks, language, content) => { + const placeholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + const cacheKey = createCodeBlockCacheKey(language.trim(), content); + codeBlockPlaceholderMap.set(placeholder, cacheKey); + codeBlockMap.set(placeholder, ``); + return prefix + indent + placeholder; + } + ); + + // Match standard code blocks (3 backticks) + result = result.replace( + /(^|\n)([\s]*)(```)([^\n]*)\n?([\s\S]*?)(?:\n\s*```|```$)/gm, + (match, prefix, indent, backticks, language, content) => { + const placeholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + const cacheKey = createCodeBlockCacheKey(language.trim(), content); + codeBlockPlaceholderMap.set(placeholder, cacheKey); + codeBlockMap.set(placeholder, ``); + return prefix + indent + placeholder; + } + ); + + // Restore code block placeholders + codeBlockMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } - if (!text) { - return div; + // Step 2: Extract and process blockquotes (can contain code blocks and inline elements) + if (processOpts.allowBlockquotes) { + const blockquoteMap = new Map(); + const lines = result.split('\n'); + let i = 0; + + while (i < lines.length) { + if (lines[i].match(/^\s*>/)) { + const blockquoteLines: string[] = []; + while (i < lines.length && lines[i].match(/^\s*>/)) { + blockquoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + + const blockquoteContent = blockquoteLines.join('\n'); + const processedContent = processMarkdown(blockquoteContent, { + ...processOpts, + allowBlockquotes: false, + allowTables: false + }); + + const placeholder = `--BLOCKQUOTE-${Math.random().toString(36).substring(2, 15)}--`; + blockquoteMap.set(placeholder, `
${processedContent}
`); + lines.splice(i - blockquoteLines.length, blockquoteLines.length, placeholder); + i = i - blockquoteLines.length + 1; + } else { + i++; + } + } + + result = lines.join('\n'); + + blockquoteMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); } - // Generate a random placeholder for newlines to preserve them during HTML sanitization - const newlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - // Replace newlines with placeholder before sanitization - const valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); - - // Sanitize the input using Angular's HTML sanitizer - const sanitizedInput = sanitizer.sanitize(SecurityContext.HTML, valueWithPlaceholders) ?? ''; - - // Restore newlines from placeholder for markdown processing. - let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); - - // Process tables first - html = html - // Remove table separator lines first - .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') - // Process table rows - .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { - // Handle escaped pipes by temporarily replacing them - const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; - const contentWithPlaceholders = htmlContent.replace(/\\\|/g, escapedPipePlaceholder); - const cells = contentWithPlaceholders.split('|').map((cell: string) => { - const trimmedCell = cell.trim(); - // Restore escaped pipes - const cellWithPipes = trimmedCell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|'); - - return cellWithPipes; - }); - // Make cell ready for markdown processing by replacing code blocks with inline code and
with newlines - const cellsWithNewlines = cells.map((cell: string) => { - // Replace multiline code blocks with single line code blocks - const cellWithoutMultilineCode = cell.replace( - /```([\s\S]*?)```/g, - (_innerMatch, inlineCodeContent) => { - return '`' + inlineCodeContent.replace(/`/g, '') + '`'; - } - ); - // Temporarily replace single line code blocks to avoid replacing
inside them - const tableInlineCodeBrPlaceholder = `--INLINE-CODE-BR--${Math.random().toString(36).substring(2, 15)}--`; - const cellWithPlaceholders = cellWithoutMultilineCode.replace( - /(`[^`]*`)/g, - inlineCodeMatch => { - return inlineCodeMatch.replace(/
/g, tableInlineCodeBrPlaceholder); - } - ); - // Replace
with newlines - const cellWithNewlines = cellWithPlaceholders.replace(//gi, '\n'); - // Restore
in inline code placeholders - const preProcessedCell = cellWithNewlines.replace( - new RegExp(tableInlineCodeBrPlaceholder, 'g'), - '
' + // Step 3: Extract and process tables + if (processOpts.allowTables) { + result = processTables(result); + } + + // Step 4: Extract and process inline code (must be before inline formatting) + if (processOpts.allowInlineCode) { + const inlineCodeMap = new Map(); + + result = result.replace(/(? { + const placeholder = `--INLINE-CODE-${Math.random().toString(36).substring(2, 15)}--`; + const escaped = content.replace(//g, '>'); + inlineCodeMap.set(placeholder, `${escaped}`); + return placeholder; + }); + + inlineCodeMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } + + // Step 5: Process links (both formats, can contain inline code) + if (processOpts.allowLinks) { + result = result.replace(/<(https?:\/\/[^\s>]+)>/g, (match, url) => { + const sanitizedUrl = sanitizeUrl(url, sanitizer); + return `${escapeHtml(url)}`; + }); + + // Images: ![alt](url) + result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + const sanitizedUrl = sanitizeUrl(url, sanitizer); + const escapedAlt = escapeHtml(alt); + return `${escapedAlt}`; + }); + + // Links: [text](url) - keep as placeholder to protect from line breaks + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { + const placeholder = `--LINK-${Math.random().toString(36).substring(2, 15)}--`; + linkPlaceholderMap.set(placeholder, { text: linkText, url }); + return placeholder; + }); + + // Auto-detect URLs + result = result.replace(/(? { + const sanitizedUrl = sanitizeUrl(match, sanitizer); + return `${escapeHtml(match)}`; + }); + } + + // Step 6: Process inline formatting only on text segments, not on block elements + result = processTextSegments(result, sanitizer); + + return result; + }; + + /** + * Create cache key for code block + */ + const createCodeBlockCacheKey = (language: string, content: string): string => { + return `${language}|||${content}`; + }; + + /** + * Create a code block element (cached) + */ + const createCodeBlockElement = (language: string, content: string): HTMLElement => { + const cacheKey = createCodeBlockCacheKey(language, content); + + return getCachedOrCreateElement( + codeBlockCache, + codeBlockCacheOrder, + CACHE_SIZE, + cacheKey, + () => { + // Escape HTML for code blocks + const displayContent = content.replace(//g, '>'); + + // Sanitize the display content + const sanitized = sanitizer.sanitize(SecurityContext.HTML, displayContent) ?? ''; + + const languageLabel = language + ? `${escapeHtml(language)}` + : ''; + const headerContent = languageLabel + ? `
${languageLabel}
` + : ''; + const wrapperClass = headerContent ? 'code-wrapper has-header' : 'code-wrapper'; + return `
${headerContent}
${sanitized}
`; + }, + docRef + ); + }; + + /** + * Process tables + */ + const processTables = (input: string): string => { + const lines = input.split('\n'); + const tableMap = new Map(); + let i = 0; + + while (i < lines.length) { + if (lines[i].match(/^\|.+/)) { + const tableLines: string[] = []; + let hasSeparator = false; + + while (i < lines.length && lines[i].match(/^\|.+/)) { + const line = lines[i]; + if (line.match(/^\|\s*[-:]+/)) { + hasSeparator = true; + } else { + tableLines.push(line); + } + i++; + } + + if (tableLines.length > 0) { + const placeholder = `--TABLE-${Math.random().toString(36).substring(2, 15)}--`; + tablePlaceholderMap.set(placeholder, JSON.stringify({ hasSeparator, tableLines })); + tableMap.set(placeholder, ``); + + lines.splice( + i - tableLines.length - (hasSeparator ? 1 : 0), + tableLines.length + (hasSeparator ? 1 : 0), + placeholder ); - return preProcessedCell; + i = i - tableLines.length - (hasSeparator ? 1 : 0) + 1; + } + } else { + i++; + } + } + + let result = lines.join('\n'); + + tableMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + + return result; + }; + + /** + * Create cache key for table + */ + const createTableCacheKey = (tableLines: string[], hasSeparator: boolean): string => { + return `${tableLines.join('|||')}|||${hasSeparator}`; + }; + + /** + * Create table HTML element (cached) + */ + const createTableElement = (tableLines: string[], hasSeparator: boolean): HTMLElement => { + const cacheKey = createTableCacheKey(tableLines, hasSeparator); + + return getCachedOrCreateElement( + tableCache, + tableCacheOrder, + CACHE_SIZE, + cacheKey, + () => { + const rows: string[] = []; + + tableLines.forEach((line, rowIndex) => { + if (!line.trim()) { + return; + } + + const escapedPipePlaceholder = `___ESCAPED_PIPE___${Math.random().toString(36).substring(2, 15)}___`; + const contentWithPlaceholders = line.replace(/\\\|/g, escapedPipePlaceholder); + const parts = contentWithPlaceholders.split('|'); + const cells = parts.slice(1, -1); + + const processedCells = cells.map(cell => { + let originalCell = cell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|').trim(); + + // Extract inline code to protect
tags within code + const inlineCodeMap = new Map(); + originalCell = originalCell.replace( + /(? { + const inlinePlaceholder = `___INLINE_CODE_${Math.random().toString(36).substring(2, 15)}___`; + inlineCodeMap.set(inlinePlaceholder, match); + return inlinePlaceholder; + } + ); + + // Convert
tags to newlines for table cells (outside of code) + originalCell = originalCell.replace(//gi, '\n'); + + // Restore inline code + inlineCodeMap.forEach((code, inlinePlaceholder) => { + originalCell = originalCell.replace(inlinePlaceholder, code); + }); + + // Process cell content for inline elements + return processMarkdown(originalCell, { + allowCodeBlocks: false, + allowBlockquotes: false, + allowTables: false, + allowInlineCode: true, + allowLinks: true + }); + }); + + const isHeader = hasSeparator && rowIndex === 0; + const tag = isHeader ? 'th' : 'td'; + const rowHtml = `${processedCells.map(cell => `<${tag}>${cell}`).join('')}`; + rows.push(rowHtml); }); - // Recursively process cell content for markdown formatting - const processedCells = cellsWithNewlines.map((cell: string) => { - return transformMarkdownText(cell, false, sanitizer); + // Filter out empty rows + const filteredRows = rows.filter(row => { + const tempTable = docRef.createElement('table'); + tempTable.innerHTML = `${row}`; + const tableCells = tempTable.querySelectorAll('td, th'); + return Array.from(tableCells).some(cell => { + const hasText = !!cell.textContent?.trim(); + const hasHtml = !!cell.innerHTML?.trim(); + return hasText || hasHtml; + }); }); - return `${processedCells.map((cell: string) => `${cell}`).join('')}`; + if (filteredRows.length === 0) { + return '
'; + } + + let tableHtml = ''; + + if (hasSeparator && filteredRows.length > 0) { + tableHtml += '' + filteredRows[0] + ''; + if (filteredRows.length > 1) { + tableHtml += '' + filteredRows.slice(1).join('') + ''; + } + } else { + tableHtml += '' + filteredRows.join('') + ''; + } + + tableHtml += '
'; + + return `
${tableHtml}
`; + }, + docRef + ); + }; + + /** + * Process text segments separately from block elements + */ + const processTextSegments = (input: string, domSanitizer: DomSanitizer): string => { + const blockElementRegex = + /(<(pre|blockquote|ul|ol|hr|h[1-6]|table)[^>]*>[\s\S]*?<\/\2>)|()/g; + + const parts: string[] = []; + let lastIndex = 0; + let blockMatch; + + while ((blockMatch = blockElementRegex.exec(input)) !== null) { + if (blockMatch.index > lastIndex) { + parts.push(input.slice(lastIndex, blockMatch.index)); + } + parts.push(blockMatch[0]); + lastIndex = blockMatch.index + blockMatch[0].length; + } + if (lastIndex < input.length) { + parts.push(input.slice(lastIndex)); + } + + const processedParts = parts.map(part => { + if (part.match(/^<(pre|blockquote|ul|ol|hr|h[1-6]|table)|^`); + // Initialize table data map for CSV export + tableCellData.set(currentTableIndex, new Map()); + lines.splice( i - tableLines.length - (hasSeparator ? 1 : 0), tableLines.length + (hasSeparator ? 1 : 0), @@ -286,15 +310,23 @@ export const getMarkdownRenderer = ( /** * Create cache key for table */ - const createTableCacheKey = (tableLines: string[], hasSeparator: boolean): string => { - return `${tableLines.join('|||')}|||${hasSeparator}`; + const createTableCacheKey = ( + tableLines: string[], + hasSeparator: boolean, + tableIndex: number + ): string => { + return `${tableLines.join('|||')}|||${hasSeparator}|||${tableIndex}|||${options?.downloadTableButton ?? ''}`; }; /** * Create table HTML element (cached) */ - const createTableElement = (tableLines: string[], hasSeparator: boolean): HTMLElement => { - const cacheKey = createTableCacheKey(tableLines, hasSeparator); + const createTableElement = ( + tableLines: string[], + hasSeparator: boolean, + tableIndex: number + ): HTMLElement => { + const cacheKey = createTableCacheKey(tableLines, hasSeparator, tableIndex); return getCachedOrCreateElement( tableCache, @@ -303,6 +335,7 @@ export const getMarkdownRenderer = ( cacheKey, () => { const rows: string[] = []; + const cellData = tableCellData.get(tableIndex); tableLines.forEach((line, rowIndex) => { if (!line.trim()) { @@ -314,7 +347,7 @@ export const getMarkdownRenderer = ( const parts = contentWithPlaceholders.split('|'); const cells = parts.slice(1, -1); - const processedCells = cells.map(cell => { + const processedCells = cells.map((cell, cellIndex) => { let originalCell = cell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|').trim(); // Extract inline code to protect
tags within code @@ -336,6 +369,13 @@ export const getMarkdownRenderer = ( originalCell = originalCell.replace(inlinePlaceholder, code); }); + // Store in cellData for CSV export + if (cellData) { + const rowData = cellData.get(rowIndex) ?? new Map(); + rowData.set(cellIndex, originalCell); + cellData.set(rowIndex, rowData); + } + // Process cell content for inline elements return processMarkdown(originalCell, { allowCodeBlocks: false, @@ -368,7 +408,10 @@ export const getMarkdownRenderer = ( return '
'; } - let tableHtml = ''; + let tableHtml = + '
'; if (hasSeparator && filteredRows.length > 0) { tableHtml += '' + filteredRows[0] + ''; @@ -381,7 +424,18 @@ export const getMarkdownRenderer = ( tableHtml += '
'; - return `
${tableHtml}
`; + // Add download button if enabled + let downloadButton = ''; + if (options?.downloadTableButton) { + const tableId = tableHtml.match(/id="([^"]+)"/)?.[1] ?? ''; + const translatedLabel = options.translateSync + ? options.translateSync(options.downloadTableButton) + : options.downloadTableButton; + const buttonLabel = escapeHtml(translatedLabel); + downloadButton = ``; + } + + return `
${tableHtml}
${downloadButton}
`; }, docRef ); @@ -572,8 +626,8 @@ export const getMarkdownRenderer = ( const tableDataJson = tablePlaceholderMap.get(placeholderId); if (tableDataJson) { try { - const { hasSeparator, tableLines } = JSON.parse(tableDataJson); - const cachedElement = createTableElement(tableLines, hasSeparator); + const { tableIndex, hasSeparator, tableLines } = JSON.parse(tableDataJson); + const cachedElement = createTableElement(tableLines, hasSeparator, tableIndex); if (cachedElement) { commentsToReplace.push({ comment, element: cachedElement }); } @@ -593,6 +647,56 @@ export const getMarkdownRenderer = ( } }); + // Add event listeners for table download buttons (browser-only) + if (isInBrowser) { + div.querySelectorAll('.download-table-btn').forEach(btn => { + btn.addEventListener('click', e => { + const button = e.target as HTMLButtonElement; + const tableId = button.getAttribute('data-table-id'); + const tableIndexStr = button.getAttribute('data-table-index'); + if (!tableId || tableIndexStr === null) return; + + const tableElement = div.querySelector(`#${tableId}`) as HTMLTableElement; + if (!tableElement) return; + + const tblIndex = parseInt(tableIndexStr, 10); + const tableData = tableCellData.get(tblIndex); + + const tableRows = Array.from(tableElement.querySelectorAll('tr')); + const csv = tableRows + .filter(row => { + const rowCells = Array.from(row.querySelectorAll('td, th')); + return rowCells.some(cell => (cell.textContent ?? '').trim().length > 0); + }) + .map((row, rowIndex) => { + const rowCells = Array.from(row.querySelectorAll('td, th')); + return rowCells + .map((cell, columnIndex) => { + const cellText = + tableData?.get(rowIndex)?.get(columnIndex) ?? cell.textContent ?? ''; + if (cellText.includes(',') || cellText.includes('"') || cellText.includes('\n')) { + return `"${cellText.replace(/"/g, '""')}"`; + } + return cellText; + }) + .join(','); + }) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = docRef.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', 'table.csv'); + link.style.visibility = 'hidden'; + docRef.body.appendChild(link); + link.click(); + docRef.body.removeChild(link); + URL.revokeObjectURL(url); + }); + }); + } + return div; }; }; diff --git a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts index 1dc6b9b3ee..fe3d848a79 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts @@ -3,10 +3,11 @@ * SPDX-License-Identifier: MIT */ import { DOCUMENT, isPlatformBrowser } from '@angular/common'; -import { Component, effect, inject, input, ElementRef, PLATFORM_ID } from '@angular/core'; +import { Component, computed, effect, inject, input, ElementRef, PLATFORM_ID } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { injectSiTranslateService, t } from '@siemens/element-translate-ng/translate'; -import { getMarkdownRenderer } from './markdown-renderer'; +import { getMarkdownRenderer, type MarkdownRendererOptions } from './markdown-renderer'; /** * Component to display markdown text, uses the {@link getMarkdownRenderer} function internally, relies on `markdown-content` theme class. @@ -19,6 +20,7 @@ import { getMarkdownRenderer } from './markdown-renderer'; export class SiMarkdownRendererComponent { private sanitizer = inject(DomSanitizer); private hostElement = inject(ElementRef); + private translateService = injectSiTranslateService(); private platformId = inject(PLATFORM_ID); private isBrowser = isPlatformBrowser(this.platformId); private doc = inject(DOCUMENT); @@ -29,20 +31,40 @@ export class SiMarkdownRendererComponent { */ readonly text = input(); - private markdownRenderer = getMarkdownRenderer( - this.sanitizer, - undefined, - this.doc, - this.isBrowser + /** + * Do not display the download CSV button for tables. + * @defaultValue false + */ + readonly disableDownloadButton = input(false); + + /** + * Label for the download CSV button. + * @defaultValue + * ``` + * t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) + * ``` + */ + readonly downloadButtonLabel = input( + t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) ); + private readonly markdownRenderer = computed(() => { + const options: MarkdownRendererOptions = { + downloadTableButton: !this.disableDownloadButton() ? this.downloadButtonLabel() : undefined, + translateSync: this.translateService.translateSync.bind(this.translateService) + }; + + return getMarkdownRenderer(this.sanitizer, options, this.doc, this.isBrowser); + }); + constructor() { effect(() => { const contentValue = this.text(); const containerEl = this.hostElement.nativeElement; + const renderer = this.markdownRenderer(); if (containerEl) { - const formattedNode = this.markdownRenderer(contentValue ?? ''); + const formattedNode = renderer(contentValue ?? ''); containerEl.innerHTML = ''; containerEl.appendChild(formattedNode); } diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index d26ed474bd..3c02215679 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -173,6 +173,7 @@ export interface SiTranslatableKeys { 'SI_LOGIN_BASIC.USERNAME'?: string; 'SI_LOGIN_SINGLE-SIGN-ON.LOGIN_SIGN_UP'?: string; 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; + 'SI_MARKDOWN_RENDERER.DOWNLOAD'?: string; 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; 'SI_NAVBAR_VERTICAL.COLLAPSE'?: string; diff --git a/projects/element-theme/src/styles/components/_markdown.scss b/projects/element-theme/src/styles/components/_markdown.scss index 0075d2b776..0364759ae1 100644 --- a/projects/element-theme/src/styles/components/_markdown.scss +++ b/projects/element-theme/src/styles/components/_markdown.scss @@ -137,6 +137,18 @@ overflow-x: auto; } + .download-table-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: variables.$element-radius-1; + } + + .download-table-btn .icon { + margin-block: 0; + } + table { inline-size: 100%; min-inline-size: 100%;