diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss index 00b5725ae0..57e3d25101 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss @@ -28,6 +28,8 @@ $table-header-background-color: #f9fbff; $table-header-text-color: rgba(33, 37, 41, .6); $table-text-color: rgba(15, 22, 29, 0.8); +$gallery-border-hover-color: #2a7dec; + :host { display: block; font-size: 14px; @@ -392,8 +394,10 @@ $table-text-color: rgba(15, 22, 29, 0.8); .tb-gallery-images { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; + margin-top: 8px; + margin-bottom: 8px; .tb-gallery-image { position: relative; @@ -407,7 +411,7 @@ $table-text-color: rgba(15, 22, 29, 0.8); transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; display: block; &:hover { - border-color: #3d50f5; + border-color: $gallery-border-hover-color; transform: translateY(-2px); box-shadow: 0 6px 18px #0000002e; outline: none; @@ -453,30 +457,5 @@ $table-text-color: rgba(15, 22, 29, 0.8); } } } - - .tb-gallery { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 12px 0; - } - - .tb-gallery-img { - max-height: 160px; - border-radius: 4px; - border: 1px solid rgba(0, 0, 0, 0.12); - cursor: pointer; - transition: max-height 0.2s ease; - object-fit: contain; - - &:hover { - border-color: rgba(0, 0, 0, 0.24); - } - - &.tb-gallery-img-expanded { - max-height: none; - max-width: 100%; - } - } } } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts index 417054b78a..57df5a8074 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts @@ -30,12 +30,13 @@ import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { + escapeHtmlAttr, replaceItemLinkPlaceholders, - resolveDocLinkPlaceholders + resolveDocLinkPlaceholders, + sanitizeInlineHtml } from '@home/components/iot-hub/iot-hub-markdown.utils'; import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models'; -import PhotoSwipeLightbox from 'photoswipe/lightbox'; -import PhotoSwipe from 'photoswipe'; +import { isNotEmptyStr } from '@core/utils'; @Component({ selector: 'tb-iot-hub-markdown', @@ -99,82 +100,6 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges { onReady() { const container = this.elementRef.nativeElement; - const galleryImages = container.querySelectorAll('.tb-gallery-images'); - const lightbox = new PhotoSwipeLightbox({ - gallery: galleryImages, - children: '.tb-gallery-image', - pswpModule: PhotoSwipe, - counter: false, - bgOpacity: 0 - }); - lightbox.addFilter('domItemData', (itemData, element) => { - const image = element.querySelector('img'); - itemData.src = image.src; - itemData.width = image.naturalWidth; - itemData.height = image.naturalHeight; - itemData.thumbCropped = true; - return itemData; - }); - lightbox.on('change', () => { - const item = lightbox.pswp.currSlide.content.element;// element.querySelector('img'); - item.style.display = 'block'; - item.style.maxWidth = '90vw'; - item.style.maxHeight = '78vh'; - item.style.objectFit = 'contain'; - item.style.borderRadius = '4px'; - item.style.boxShadow = '0 20px 60px #00000080'; - }); - lightbox.on('uiRegister', () => { - lightbox.pswp.element.style.background = '#0a0a148c'; - lightbox.pswp.element.style.backdropFilter = 'blur(18px)'; - lightbox.pswp.element.style.setProperty('-webkit-backdrop-filter', 'blur(18px)'); - lightbox.pswp.ui.registerElement({ - name: 'custom-caption', - order: 9, - isButton: false, - appendTo: 'root', - html: '', - onInit: (el, pswp) => { - el.style.position = 'fixed'; - el.style.bottom = '1.5rem'; - el.style.left = '50%'; - el.style.transform = 'translate(-50%)'; - el.style.zIndex = '10000'; - el.style.display = 'flex'; - el.style.flexDirection = 'column'; - el.style.alignItems = 'center'; - el.style.gap = '.25rem'; - el.style.maxWidth = '80vw'; - el.style.textAlign = 'center'; - el.style.pointerEvents = 'none'; - const caption = el.querySelector('.tb-gallery-caption'); - caption.style.color = '#fff'; - caption.style.fontSize = '1.125rem'; - caption.style.lineHeight = '1.5'; - caption.style.background = '#000000a6'; - caption.style.padding = '.5rem 1.25rem'; - caption.style.borderRadius = '8px'; - caption.style.backdropFilter = 'blur(8px)'; - caption.style.setProperty('-webkit-backdrop-filter', 'blur(8px)'); - const counter = el.querySelector('.tb-gallery-counter'); - counter.style.color = '#fff9'; - counter.style.fontSize = '.8rem'; - lightbox.pswp.on('change', () => { - counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems(); - const currSlideElement = lightbox.pswp.currSlide.data.element; - let captionHTML = ''; - if (currSlideElement) { - const imageTooltip = currSlideElement.querySelector('.tb-image-tooltip'); - if (imageTooltip) { - captionHTML = imageTooltip.innerHTML; - } - } - caption.innerHTML = captionHTML || ''; - }); - } - }); - }); - lightbox.init(); this.ready.emit(container); } @@ -233,6 +158,39 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges { } private resolveVariables(content: string): string { + // Image gallery is handled first because its inner ${...} contents + // may include nested braces and span multiple lines, which the + // generic ${key} regex below cannot parse. + // + // Format: ${images.gallery({src: 'p1', alt: 'a1', caption: 'c1'}, {src: 'p2'}, ...)} + // Each image entry is a JS object literal with a required `src` + // and optional `alt` / `caption` string fields. + content = content.replace(/\$\{\s*images\.gallery\(([\s\S]*?)\)\s*}/g, (_match, inner: string) => { + const objects: string[] = inner.match(/\{[\s\S]*?}/g) || []; + const items = objects + .map((obj: string) => { + const src = (obj.match(/src\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + const alt = (obj.match(/alt\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + const caption = (obj.match(/caption\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + return { src: this.resolveImage(src), alt, caption }; + }) + .filter((item: { src?: string }) => !!item.src); + const images = items + .map(item => { + let galleryImageHtml = ``; + return galleryImageHtml; + }) + .join(''); + return ``; + }); + return content.replace(/\$\{([^}]+)}/g, (_match, key) => { // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); @@ -242,19 +200,6 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges { const icons: Record = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' }; return `
${icons[type]}${text}
`; } - // Image gallery: ${images.gallery(path1,path2,path3)} - const galleryMatch = key.match(/^images\.gallery\((.+)\)$/); - if (galleryMatch) { - const paths = galleryMatch[1].split(',').map((p: string) => p.trim()); - const images = paths - .map((p: string) => this.resolveImage(p)) - .filter((src: string | undefined) => !!src) - .map((src: string) => ``) - .join(''); - return ``; - } // Special variables const res = this.onResolveVariable(key); diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts index 30872c3b20..6685a898f5 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts @@ -15,10 +15,7 @@ /// export const ITEM_LINK_PLACEHOLDER_REGEX = - /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; - -export const ITEM_LINK_KEY_REGEX = - /^item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; + /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})}/g; export function itemLinkCardTag(itemId: string): string { return ``; @@ -61,11 +58,11 @@ function buildDocLinkButton(url: string, text: string, icon: string): string { `${icon}${safeText}`; } -function escapeHtml(value: string): string { +export function escapeHtml(value: string): string { return value.replace(/[&<>]/g, ch => ch === '&' ? '&' : ch === '<' ? '<' : '>'); } -function escapeHtmlAttr(value: string): string { +export function escapeHtmlAttr(value: string): string { return value.replace(/[&<>"']/g, ch => { switch (ch) { case '&': return '&'; @@ -77,3 +74,51 @@ function escapeHtmlAttr(value: string): string { }); } +// Inline-only tags + a small attribute whitelist that are safe to keep +// in caption strings (or other small bits of user-authored HTML inside +// generated markdown). Anything else is dropped — disallowed tags are +// replaced by their text content and disallowed attributes are removed. +const SAFE_INLINE_TAGS: ReadonlySet = new Set([ + 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'MARK', + 'SMALL', 'SUB', 'SUP', 'BR', 'CODE', 'SPAN' +]); + +const SAFE_INLINE_ATTRS: ReadonlySet = new Set(['class', 'style']); + +export function sanitizeInlineHtml(value: string): string { + if (!value) { + return ''; + } + const doc = new DOMParser().parseFromString(`
${value}
`, 'text/html'); + const root = doc.body.firstElementChild as HTMLElement | null; + if (!root) { + return ''; + } + const sanitize = (parent: Element): void => { + const children = Array.from(parent.childNodes); + for (const node of children) { + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + const el = node as Element; + if (!SAFE_INLINE_TAGS.has(el.tagName)) { + // Replace disallowed elements with their plain text content. + parent.replaceChild(doc.createTextNode(el.textContent || ''), el); + continue; + } + for (const attr of Array.from(el.attributes)) { + if (!SAFE_INLINE_ATTRS.has(attr.name)) { + el.removeAttribute(attr.name); + continue; + } + if (attr.name === 'style' && /(expression\s*\(|javascript:|url\s*\()/i.test(attr.value)) { + el.removeAttribute(attr.name); + } + } + sanitize(el); + } + }; + sanitize(root); + return root.innerHTML; +} + diff --git a/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts new file mode 100644 index 0000000000..f4bd39e72b --- /dev/null +++ b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts @@ -0,0 +1,219 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import PhotoSwipe from 'photoswipe'; +import cssjs from '@core/css/css'; + +const PHOTO_GALLERY_STYLE_ID = 'photoswipe-gallery-style'; +const PHOTO_GALLERY_CLASS = 'tb-photoswipe-gallery'; +const PHOTO_GALLERY_STYLE = + '{\n'+ + ' background: rgba(10, 10, 20, 0.55);\n' + + ' backdrop-filter: blur(18px);\n' + + ' opacity: 1;\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption {\n' + + ' position: fixed;\n' + + ' bottom: 1.5rem;\n' + + ' left: 50%;\n' + + ' transform: translate(-50%);\n' + + ' z-index: 10000;\n' + + ' display: flex;\n' + + ' flex-direction: column;\n' + + ' align-items: center;\n' + + ' gap: .25rem;\n' + + ' max-width: 80vw;\n' + + ' text-align: center;\n' + + ' pointer-events: none;\n' + + ' line-height: 1.75;\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption .tb-gallery-caption {\n' + + ' color: #fff;\n' + + ' font-size: 1.125rem;\n' + + ' line-height: 1.5;\n' + + ' background: #000000a6;\n' + + ' padding: .5rem 1.25rem;\n' + + ' border-radius: 8px;\n' + + ' backdrop-filter: blur(8px);\n' + + ' -webkit-backdrop-filter: blur(8px);\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption .tb-gallery-counter {\n' + + ' color: #fff9;\n' + + ' font-size: .8rem;\n' + + '}\n'+ + '\n' + + '.pswp__item img.pswp__img {\n' + + ' display: block;\n' + + ' max-width: 90vw;\n' + + ' max-height: 78vh;\n' + + ' object-fit: contain;\n' + + ' border-radius: 4px; \n' + + ' box-shadow: 0 20px 60px #00000080;\n' + + '}\n' + + '\n' + + '.pswp__item .pswp__img--placeholder {\n' + + ' border-radius: 4px; \n' + + '}\n' + + '\n' + + '.pswp__button {\n' + + ' border-radius: 50%;\n' + + ' border: 1px solid rgba(255, 255, 255, .2);\n' + + ' background: #1e1e2899;\n' + + ' backdrop-filter: blur(8px);\n' + + ' -webkit-backdrop-filter: blur(8px);\n' + + ' color: #fff;\n' + + ' transition: background .18s ease, transform .18s ease;\n' + + ' outline: none;\n' + + '}\n' + + '\n' + + '.pswp__button:hover {\n' + + ' background: #3c3c50d9;\n' + + ' transform: scale(1.08);\n' + + ' outline: none;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close {\n' + + ' width: 40px;\n' + + ' height: 40px;\n' + + ' margin-top: 16px;\n' + + ' margin-right: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close .pswp__icn {\n' + + ' width: 24px;\n' + + ' height: 24px;\n' + + ' top: 7px;\n' + + ' left: 7px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow {\n' + + ' width: 48px;\n' + + ' height: 48px;\n' + + ' margin-top: -24px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow .pswp__icn {\n' + + ' width: 32px;\n' + + ' height: 32px;\n' + + ' margin-top: 0;\n' + + ' top: 7px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--prev {\n' + + ' left: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--prev .pswp__icn {\n' + + ' left: 12px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--next {\n' + + ' right: 16px\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--next .pswp__icn { \n' + + ' right: 12px;\n' + + '}'; + +@Directive({ + selector: '[tbPhotoSwipeGallery]', + standalone: false +}) +export class PhotoSwipeGalleryDirective implements OnInit, OnDestroy { + + @Input() galleryChildrenSelector = '.tb-image'; + @Input() imageCaptionSelector = '.tb-image-tooltip'; + + private lightbox: PhotoSwipeLightbox; + + constructor( + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + this.initPhotoSwipeGalleryStyle(); + this.lightbox = new PhotoSwipeLightbox({ + gallery: this.elementRef.nativeElement, + children: this.galleryChildrenSelector, + pswpModule: PhotoSwipe, + counter: false, + bgOpacity: 0, + mainClass: PHOTO_GALLERY_CLASS + }); + this.lightbox.addFilter('domItemData', (itemData, element) => { + let image: HTMLImageElement; + if (element instanceof HTMLImageElement) { + image = element; + } else { + image = element.querySelector('img'); + } + itemData.src = image.src; + itemData.width = image.naturalWidth; + itemData.height = image.naturalHeight; + itemData.thumbCropped = true; + return itemData; + }); + this.lightbox.on('uiRegister', () => { + this.lightbox.pswp.ui.registerElement({ + name: 'tb-photoswipe-caption', + order: 9, + isButton: false, + appendTo: 'root', + html: '', + onInit: (el, pswp) => { + const caption = el.querySelector('.tb-gallery-caption'); + const counter = el.querySelector('.tb-gallery-counter'); + this.lightbox.pswp.on('change', () => { + counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems(); + const currSlideElement = this.lightbox.pswp.currSlide.data.element; + let imageTooltip: Element; + if (currSlideElement) { + imageTooltip = currSlideElement.querySelector(this.imageCaptionSelector); + } + if (imageTooltip) { + caption.style.display = 'block'; + caption.innerHTML = imageTooltip.innerHTML || ''; + } else { + caption.style.display = 'none'; + } + }); + } + }); + }); + this.lightbox.init(); + } + + ngOnDestroy(): void { + if (this.lightbox) { + this.lightbox.destroy(); + } + } + + private initPhotoSwipeGalleryStyle(): void { + const existingElement = document.getElementById(PHOTO_GALLERY_STYLE_ID); + if (!existingElement) { + const cssParser = new cssjs(); + cssParser.testMode = false; + cssParser.cssPreviewNamespace = PHOTO_GALLERY_CLASS; + cssParser.createStyleElement(PHOTO_GALLERY_STYLE_ID, PHOTO_GALLERY_STYLE); + } + } +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts index eebefbbdb4..97c3a3f7b6 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts @@ -232,7 +232,7 @@ export enum InstallStepType { RULE_CHAIN = 'RULE_CHAIN' } -export const ENTITY_STEP_TYPES: Set = new Set([ +export const ENTITY_STEP_TYPES = new Set([ InstallStepType.DEVICE_PROFILE, InstallStepType.DEVICE, InstallStepType.GATEWAY, @@ -327,49 +327,3 @@ export interface EntityStepProgress { conflictType?: ConflictType; resolution?: string; } - -export interface DocLinks { - productURL?: string; - datasheetURL?: string; -} - -export interface DocLinkLabels { - productPage: string; - datasheet: string; -} - -export function resolveDocLinkPlaceholders( - markdown: string, - name: string, - links: DocLinks, - labels: DocLinkLabels -): string { - return markdown - .replace(/\$\{product\.button}/g, () => - links.productURL ? buildDocLinkButton(links.productURL, `${name} ${labels.productPage}`, 'open_in_new') : '') - .replace(/\$\{datasheet\.button}/g, () => - links.datasheetURL ? buildDocLinkButton(links.datasheetURL, `${name} ${labels.datasheet}`, 'description') : ''); -} - -function buildDocLinkButton(url: string, text: string, icon: string): string { - const safeUrl = escapeHtmlAttr(url); - const safeText = escapeHtml(text); - return `` + - `${icon}${safeText}`; -} - -function escapeHtml(value: string): string { - return value.replace(/[&<>]/g, ch => ch === '&' ? '&' : ch === '<' ? '<' : '>'); -} - -function escapeHtmlAttr(value: string): string { - return value.replace(/[&<>"']/g, ch => { - switch (ch) { - case '&': return '&'; - case '<': return '<'; - case '>': return '>'; - case '"': return '"'; - default: return '''; - } - }); -} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 21addee9e1..6fe78f7ee0 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -229,6 +229,7 @@ import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle'; +import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -387,6 +388,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncateWithTooltipDirective, ContextMenuDirective, ChipOverflowDirective, + PhotoSwipeGalleryDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -652,6 +654,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncateWithTooltipDirective, ContextMenuDirective, ChipOverflowDirective, + PhotoSwipeGalleryDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe,