From e0be2025f6216869caaecad6606efb14d38937ba Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 7 May 2026 18:28:57 +0300 Subject: [PATCH] feat(iot-hub): add PhotoSwipe lightbox for markdown image galleries - Wire photoswipe (5.4.4) into ui-ngx and load its CSS via angular.json. - Replace the in-place ".tb-gallery-img-expanded" toggle in TbIotHubMarkdownComponent.onReady with a PhotoSwipe lightbox bound to each ".tb-gallery-images" container, picking up ".tb-gallery-image" anchors. Items are sized from the underlying image's naturalWidth/Height; the lightbox slide uses contained rendering with a max viewport sized 90vw / 78vh and a 0-opacity backdrop. - Style the gallery: 240px-min auto-fill grid with 12px gap, 115px thumbnail strip with object-fit contained images, hover lift (border + translate + shadow) and a fade-in caption tooltip bottom-pinned over the thumbnail. --- ui-ngx/angular.json | 3 +- ui-ngx/package.json | 1 + .../iot-hub/iot-hub-markdown.component.scss | 64 ++++++++++++++ .../iot-hub/iot-hub-markdown.component.ts | 87 +++++++++++++++++-- ui-ngx/yarn.lock | 5 ++ 5 files changed, 152 insertions(+), 8 deletions(-) diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index a5e140322b..007af200ba 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -109,7 +109,8 @@ "node_modules/@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css", "node_modules/prismjs/themes/prism.css", "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css", - "node_modules/ace-diff/dist/ace-diff.min.css" + "node_modules/ace-diff/dist/ace-diff.min.css", + "node_modules/photoswipe/dist/photoswipe.css" ], "stylePreprocessorOptions": { "includePaths": [ diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 076e2d316a..48e81c9960 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -77,6 +77,7 @@ "ngx-sharebuttons": "^17.0.0", "ngx-translate-messageformat-compiler": "^7.2.0", "objectpath": "^2.0.0", + "photoswipe": "^5.4.4", "qrcode": "^1.5.4", "raphael": "^2.3.0", "rxjs": "~7.8.2", 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 2c4ba9918d..00b5725ae0 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 @@ -390,6 +390,70 @@ $table-text-color: rgba(15, 22, 29, 0.8); } } + .tb-gallery-images { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + + .tb-gallery-image { + position: relative; + cursor: pointer; + border: 2px solid #c1c3c8; + border-radius: 8px; + overflow: hidden; + margin: 0; + padding: 0; + background: #fff; + transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; + display: block; + &:hover { + border-color: #3d50f5; + transform: translateY(-2px); + box-shadow: 0 6px 18px #0000002e; + outline: none; + .tb-image-tooltip { + opacity: 1; + transform: translateY(0); + } + } + .tb-image-container { + display: block; + height: 115px; + overflow: hidden; + background: #fff; + img { + width: 100%; + max-width: 100%; + height: 100%; + object-fit: contain; + object-position: top center; + display: block; + margin: 0; + padding: 0; + } + } + .tb-image-tooltip { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: .4rem .6rem; + background: #000000b8; + color: #fff; + font-size: .75rem; + line-height: 1.3; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + transform: translateY(4px); + transition: opacity .2s ease, transform .2s ease; + pointer-events: none; + } + } + } + .tb-gallery { display: flex; flex-wrap: wrap; 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 445c2bc326..417054b78a 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 @@ -34,6 +34,8 @@ import { resolveDocLinkPlaceholders } 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'; @Component({ selector: 'tb-iot-hub-markdown', @@ -97,13 +99,82 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges { onReady() { const container = this.elementRef.nativeElement; - // Gallery image click-to-expand - const galleryImages = container.querySelectorAll('.tb-gallery-img'); - galleryImages.forEach(img => { - img.addEventListener('click', () => { - img.classList.toggle('tb-gallery-img-expanded'); + 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); } @@ -178,9 +249,11 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges { const images = paths .map((p: string) => this.resolveImage(p)) .filter((src: string | undefined) => !!src) - .map((src: string) => ``) + .map((src: string) => ``) .join(''); - return ``; + return ``; } // Special variables diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index e9564f5279..e13220ff76 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -8742,6 +8742,11 @@ pbf@^3.2.1, pbf@^3.3.0: ieee754 "^1.1.12" resolve-protobuf-schema "^2.1.0" +photoswipe@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.4.4.tgz#e045dc036453493188d5c8665b0e8f1000ac4d6e" + integrity sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA== + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"