diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index a6c0310720..7b4f3c6c54 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -207,7 +207,6 @@ import { CheckConnectivityDialogComponent } from '@home/components/ai-model/chec import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; @NgModule({ declarations: @@ -362,7 +361,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti AIModelDialogComponent, ResourcesDialogComponent, ResourcesLibraryComponent, - SolutionInstallDialogComponent, ], imports: [ CommonModule, @@ -513,7 +511,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti AIModelDialogComponent, ResourcesDialogComponent, ResourcesLibraryComponent, - SolutionInstallDialogComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html index 0174208c41..a075cf5847 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -53,12 +53,13 @@ } @case ('instruction') { -
- - -
+ + } @case ('form') { @@ -277,9 +278,7 @@ @for (ws of wizardSteps; track ws.label) { -
- -
+
} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss index 64edf48f95..aff20e4d58 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -28,6 +28,9 @@ height: 1000px; max-width: 100%; } + @media #{$mat-gt-md} { + width: 1000px; + } } :host { @@ -197,205 +200,6 @@ } } -.tb-tab-content { - padding: 16px 24px; -} - -// Instruction view -.tb-device-install-instruction { - font-size: 14px; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - - ::ng-deep tb-markdown .tb-markdown-view { - padding: 16px 24px 24px; - - h1, h2, h3, h4, h5, h6 { - font-size: 20px; - font-weight: 600; - line-height: 24px; - letter-spacing: 0.1px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - padding: 0; - } - - > h1, > h2, > h3, > h4, > h5, > h6 { - padding: 0; - margin-top: 20px; - } - - > :first-child { - margin-top: 0; - } - - h1 { - font-size: 24px; - line-height: 32px; - padding-right: 0; - } - - p { - font-size: 14px; - font-weight: 400; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - } - - > p, > div { - padding-left: 0; - padding-right: 0; - } - - p + p { - margin-top: 8px; - } - - h1 + p, h2 + p, h3 + p, - h1 + ul, h2 + ul, h3 + ul, - h1 + ol, h2 + ol, h3 + ol { - margin-top: 8px; - } - - ul, ol { - padding-left: 21px; - padding-right: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - - + h1, + h2, + h3, + h4, + h5, + h6 { - padding-top: 0; - margin-top: 20px; - } - } - - li { - padding-bottom: 0; - margin-bottom: 0; - line-height: 24px; - } - - code:not([class*=language-]) { - font-size: 14px; - } - } - - ::ng-deep { - - .tb-download-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 6px 16px; - border: 1px solid #305680; - border-radius: 4px; - color: #305680; - font-size: 14px; - font-weight: 500; - text-decoration: none; - cursor: pointer; - - &:hover { - background: rgba(48, 86, 128, 0.08); - } - } - - .tb-doc-link-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - margin: 4px 8px 4px 0; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 6px; - background: #fff; - color: rgba(0, 0, 0, 0.76); - font-size: 14px; - font-weight: 500; - line-height: 20px; - text-decoration: none; - cursor: pointer; - - i.material-icons { - font-size: 18px; - line-height: 18px; - color: rgba(0, 0, 0, 0.54); - } - - &:hover { - background: rgba(0, 0, 0, 0.04); - border-color: rgba(0, 0, 0, 0.24); - } - } - - .tb-callout { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 12px 16px; - border-radius: 4px; - border: 1px solid; - margin: 12px 0; - font-size: 14px; - line-height: 20px; - - .tb-callout-icon { - font-size: 20px; - flex-shrink: 0; - } - - &.tb-callout-note { - background: #e3f2fd; - border-color: #90caf9; - color: #1565c0; - } - - &.tb-callout-warn { - background: #fff8e1; - border-color: #ffe082; - color: #f57f17; - } - - &.tb-callout-error { - background: #fce4ec; - border-color: #ef9a9a; - color: #c62828; - } - } - - .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%; - } - } - } -} - // Form view .tb-device-install-form { mat-form-field { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts index a5f3867046..835c1c8cb4 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -39,8 +39,6 @@ import { AttributeService } from '@core/http/attribute.service'; import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; import { generateSecret } from '@core/utils'; -import { IotHubItemLinkModule } from '../iot-hub-item-link-card/iot-hub-item-link.module'; -import { ITEM_LINK_KEY_REGEX, itemLinkCardTag } from '../iot-hub-markdown.utils'; import { installMethodIcons as INSTALL_METHOD_ICONS, installMethodLabels as INSTALL_METHOD_LABELS, @@ -53,7 +51,6 @@ import { FormFieldDefinition, FormFieldType, InstallStepType, - resolveDocLinkPlaceholders, stepTypeAliasMap } from '@shared/models/iot-hub/device-package.models'; @@ -96,8 +93,6 @@ export class TbDeviceInstallDialogComponent extends DialogComponent[] = [IotHubItemLinkModule]; - loading = true; packageInfo: DevicePackageInfo; zipFiles = new Map(); @@ -122,6 +117,8 @@ export class TbDeviceInstallDialogComponent extends DialogComponent = {}; gatewayDockerComposeContent: string | null = null; + resolveMarkdownVariable: (key: string) => string | undefined = this._resolveMarkdownVariable.bind(this); + constructor( protected store: Store, protected router: Router, @@ -239,7 +236,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { - img.addEventListener('click', () => { - img.classList.toggle('tb-gallery-img-expanded'); - }); - }); } resolveImagePath(path: string): string { @@ -473,70 +463,44 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { - if (key in this.formValues) { - return String(this.formValues[key]); - } - if (key in this.transportVars) { - return this.transportVars[key]; - } - // Special action placeholders - if (key === 'gateway.downloadButton') { - return '⬇ Download docker-compose.yml'; - } - // IoT Hub item link card: ${item-link:} - const itemLinkMatch = key.match(ITEM_LINK_KEY_REGEX); - if (itemLinkMatch) { - return itemLinkCardTag(itemLinkMatch[1]); - } - // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} - const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); - if (calloutMatch) { - const type = calloutMatch[1]; - const text = calloutMatch[2]; - 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.zipImages.get(p)) - .filter((src: string | undefined) => !!src) - .map((src: string) => ``) - .join(''); - return ``; - } - const dotIdx = key.indexOf('.'); - if (dotIdx > 0) { - const alias = key.substring(0, dotIdx); - const prop = key.substring(dotIdx + 1); - const output = this.entityOutputs.get(alias); - if (output && prop in output) { - return String((output as any)[prop]); - } + const res = this.resolveVariable(key); + if (res) { + return res; } return '${' + key + '}'; }); } - resolveImages(content: string): string { - return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { - if (path.startsWith('data:') || path.startsWith('http')) { - return match; + resolveVariable(key: string): string | undefined { + if (key in this.formValues) { + return String(this.formValues[key]); + } + if (key in this.transportVars) { + return this.transportVars[key]; + } + const dotIdx = key.indexOf('.'); + if (dotIdx > 0) { + const alias = key.substring(0, dotIdx); + const prop = key.substring(dotIdx + 1); + const output = this.entityOutputs.get(alias); + if (output && prop in output) { + return String((output as any)[prop]); } - const dataUri = this.zipImages.get(path); - return dataUri ? `![${alt}](${dataUri})` : match; - }); + } + return undefined; + } + + private _resolveMarkdownVariable(key: string): string | undefined { + const res = this.resolveVariable(key); + if (res) { + return res; + } + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + return undefined; } // --- Private --- @@ -582,7 +546,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent @if (readmeContent) { - + } @if (shouldShowChangelog()) {

{{ 'iot-hub.changelog' | translate }}

- + } } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts index fb1632f1dd..c770045ddc 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -24,15 +24,12 @@ import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTransl import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; -import { resolveDocLinkPlaceholders } from '@shared/models/iot-hub/device-package.models'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; import { getEntityDetailsPageURL } from '@core/utils'; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; -import { replaceItemLinkPlaceholders } from './iot-hub-markdown.utils'; -import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; export type IotHubItemDetailDialogMode = 'default' | 'add'; @@ -54,7 +51,6 @@ export interface IotHubItemDetailDialogData { export class TbIotHubItemDetailDialogComponent extends DialogComponent { readonly ItemType = ItemType; - readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; item: MpItemVersionView; mode: IotHubItemDetailDialogMode; showCreator: boolean; @@ -310,24 +306,8 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent this.readmeContent = replaceItemLinkPlaceholders( - this.resolveDocLinks(this.prefixResourceUrls(content || '')) - ) + content => this.readmeContent = content ); } - private prefixResourceUrls(markdown: string): string { - const baseUrl = this.iotHubApiService.baseUrl; - return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); - } - - private resolveDocLinks(markdown: string): string { - const dd = this.item.dataDescriptor; - return resolveDocLinkPlaceholders( - markdown, - this.item.name || dd?.name || '', - { productURL: dd?.productURL, datasheetURL: dd?.datasheetURL }, - { productPage: 'Product page', datasheet: 'Datasheet' } - ); - } } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html index 83b8f82b59..a7c2d3bd04 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html @@ -15,9 +15,11 @@ limitations under the License. --> - + [fallbackToPlainMarkdown]="fallbackToPlainMarkdown" + (ready)="onReady()"> 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 68278d2fc6..2c4ba9918d 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 @@ -14,14 +14,28 @@ * limitations under the License. */ -// Shared markdown styles for IoT Hub readme/description content. -// Originally in iot-hub-item-detail-dialog.component.scss (.dlg-readme). +$text-color: rgba(0,0,0,0.76); +$link-color: #2a7dec; +$copy-button-color: #2a7dec; + +$code-block-border-color: rgba(42, 125, 236, .2); +$code-default-color: #212529; +$code-text-color: #eb5757; +$code-keyword-color: #2a7dec; + +$table-border-color: rgba(42, 125, 236, .2); +$table-header-background-color: #f9fbff; +$table-header-text-color: rgba(33, 37, 41, .6); +$table-text-color: rgba(15, 22, 29, 0.8); + :host { display: block; font-size: 14px; line-height: 24px; letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); + color: $text-color; + + --mat-button-outlined-label-text-color: #{$text-color}; ::ng-deep tb-markdown .tb-markdown-view { padding: 16px 24px 24px; @@ -40,7 +54,7 @@ font-weight: 600; line-height: 24px; letter-spacing: 0.1px; - color: rgba(0, 0, 0, 0.76); + color: $text-color; margin: 0; padding: 0; } @@ -62,7 +76,7 @@ font-weight: 400; line-height: 24px; letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); + color: $text-color; margin: 0; } @@ -89,7 +103,7 @@ font-size: 14px; line-height: 24px; letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); + color: $text-color; + h1, + h2, + h3, + h4, + h5, + h6 { padding-top: 0; @@ -108,35 +122,30 @@ line-height: 24px; } - // Images — ported from markdown.component.scss. img { max-width: 100%; } - // Links — ported from markdown.component.scss. - a { + a:not(.mdc-button) { font-weight: 500; - color: #2a7dec; + color: $link-color; text-decoration: none; border: none; &:hover { - color: #2a7dec; + color: $link-color; text-decoration: underline; border: none; } } - // Inline code — ported from markdown.component.scss. code:not([class*=language-]) { - color: #eb5757; + color: $code-text-color; font-family: monospace; font-size: 14px; } - // Code-wrapper + clipboard button — ported from markdown.component.scss - // so the inline copy affordance and hover state work without - // depending on the default markdown styles. + // Code-wrapper + clipboard button div.code-wrapper { position: relative; @@ -160,7 +169,7 @@ padding: 6px 8px 0; top: 1px; transition: .2s; - color: #2a7dec; + color: $copy-button-color; background: rgba(255, 255, 255, .85); backdrop-filter: blur(4px); opacity: 0; @@ -191,7 +200,7 @@ cursor: pointer; pre[class*="language-"] { - border: solid 1px #2a7dec; + border: solid 1px $copy-button-color; } button.clipboard-btn { @@ -218,25 +227,25 @@ } } - // Code blocks (Prism) — ported from markdown.component.scss. + // Code blocks (Prism) pre[class*="language-"] { font-size: 14px; - border: 1px solid rgba(42, 125, 236, .2); + border: 1px solid $code-block-border-color; border-radius: 4px; background: 0 0; padding: 8px 16px; - color: #212529; + color: $code-default-color; .token.atrule, .token.attr-value, .token.keyword { - color: #2a7dec; + color: $code-keyword-color; } .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { - color: #eb5757; + color: $code-text-color; } .token.punctuation { - color: #212529; + color: $code-default-color; } &.line-numbers { @@ -268,11 +277,10 @@ } } - // Tables — ported from markdown.component.scss since - // applyDefaultMarkdownStyle is false on tb-markdown. + // Tables > table { width: 100%; - border: 1px solid rgba(42, 125, 236, .2); + border: 1px solid $table-border-color; border-radius: 4px; border-collapse: unset; border-spacing: 0; @@ -286,11 +294,11 @@ } > thead { - background-color: #f9fbff; - color: rgba(33, 37, 41, .6); + background-color: $table-header-background-color; + color: $table-header-text-color; > tr > th { - border-bottom: 1px solid rgba(42, 125, 236, .2); + border-bottom: 1px solid $table-border-color; font-size: 14px; padding: 8px 12px; text-align: left; @@ -307,7 +315,7 @@ > tbody { > tr:not(:last-child) > td { - border-bottom: 1px solid rgba(42, 125, 236, .2); + border-bottom: 1px solid $table-border-color; } > tr > td { @@ -315,7 +323,7 @@ padding: 8px 12px; text-align: left; margin: 0; - color: rgba(15, 22, 29, 0.8); + color: $table-text-color; @media screen and (max-width: 400px) { font-size: 12px; padding: 8px 4px; @@ -347,32 +355,64 @@ } } - .tb-doc-link-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - margin: 4px 8px 4px 0; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 6px; - background: #fff; - color: rgba(0, 0, 0, 0.76); + .tb-callout { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + border-radius: 4px; + border: 1px solid; + margin: 12px 0; font-size: 14px; - font-weight: 500; line-height: 20px; - text-decoration: none; - cursor: pointer; - i.material-icons { - font-size: 18px; - line-height: 18px; - color: rgba(0, 0, 0, 0.54); + .tb-callout-icon { + font-size: 20px; + flex-shrink: 0; } + &.tb-callout-note { + background: #e3f2fd; + border-color: #90caf9; + color: #1565c0; + } + + &.tb-callout-warn { + background: #fff8e1; + border-color: #ffe082; + color: #f57f17; + } + + &.tb-callout-error { + background: #fce4ec; + border-color: #ef9a9a; + color: #c62828; + } + } + + .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 { - background: rgba(0, 0, 0, 0.04); 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 b8e9d4139c..445c2bc326 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 @@ -14,9 +14,26 @@ /// limitations under the License. /// -import { Component, Input, Type } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + Type +} from '@angular/core'; import { coerceBoolean } from '@shared/decorators/coercion'; import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { + replaceItemLinkPlaceholders, + resolveDocLinkPlaceholders +} from '@home/components/iot-hub/iot-hub-markdown.utils'; +import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models'; @Component({ selector: 'tb-iot-hub-markdown', @@ -24,10 +41,18 @@ import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link templateUrl: './iot-hub-markdown.component.html', styleUrls: ['./iot-hub-markdown.component.scss'] }) -export class TbIotHubMarkdownComponent { +export class TbIotHubMarkdownComponent implements OnInit, OnChanges { @Input() data: string | undefined; + @Input() item: MpItemVersionView | undefined; + + @Input() packageInfo: DevicePackageInfo | undefined; + + @Input() imageMap: Map | undefined; + + @Input() onResolveVariable: (key: string) => string | undefined = () => undefined; + @Input() @coerceBoolean() lineNumbers = false; @@ -36,5 +61,134 @@ export class TbIotHubMarkdownComponent { @coerceBoolean() fallbackToPlainMarkdown = false; + @Input() + codeBlockMaxHeightPx: number; + + @Output() ready = new EventEmitter(); + + additionalStyles: string[] = []; + + parsedData: string; + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; + + constructor( + private iotHubApiService: IotHubApiService, + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + if (this.codeBlockMaxHeightPx) { + const codeBlockMaxHeightStyle = `pre[class*="language-"] \n{ + max-height: ${this.codeBlockMaxHeightPx}px;\n + }`; + this.additionalStyles.push(codeBlockMaxHeightStyle); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (propName === 'data' && change.currentValue !== change.previousValue) { + this.parsedData = this.parseData(this.data); + } + } + } + + 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'); + }); + }); + this.ready.emit(container); + } + + private parseData(content: string | undefined): string { + let parsed = this.prefixResourceUrls(content || ''); + parsed = this.resolveDocLinks(parsed); + parsed = replaceItemLinkPlaceholders(parsed); + parsed = this.resolveImages(parsed); + parsed = this.resolveVariables(parsed); + return parsed; + } + + private prefixResourceUrls(markdown: string): string { + const baseUrl = this.iotHubApiService.baseUrl; + return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); + } + + private resolveDocLinks(markdown: string): string { + if (this.item || this.packageInfo) { + const dd = this.item ? this.item.dataDescriptor : this.packageInfo; + return resolveDocLinkPlaceholders( + markdown, + this.item?.name || dd?.name || '', + { productURL: dd?.productURL, datasheetURL: dd?.datasheetURL }, + { productPage: 'Product page', datasheet: 'Datasheet' } + ); + } else { + return markdown; + } + } + + private resolveImages(content: string): string { + if (this.imageMap) { + return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { + if (path.startsWith('data:') || path.startsWith('http')) { + return match; + } + const dataUri = this.imageMap.get(path); + return dataUri ? `![${alt}](${dataUri})` : match; + }); + } else { + return content; + } + } + + private resolveImage(uri: string): string { + if (this.imageMap && uri) { + if (uri.startsWith('data:') || uri.startsWith('http')) { + return uri; + } + const dataUri = this.imageMap.get(uri); + return dataUri ? dataUri : uri; + } else { + return uri; + } + } + + private resolveVariables(content: string): string { + return content.replace(/\$\{([^}]+)}/g, (_match, key) => { + // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} + const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); + if (calloutMatch) { + const type = calloutMatch[1]; + const text = calloutMatch[2]; + 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); + if (res) { + return res; + } + return '${' + 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 b0a87fb11e..30872c3b20 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 @@ -30,3 +30,50 @@ export function replaceItemLinkPlaceholders(markdown: string): string { } return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); } + +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/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts index 71f7b93716..e11528aa6b 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts @@ -26,7 +26,7 @@ import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; import { resolveEntityDetailsUrl } from './iot-hub-components.models'; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; export interface IotHubUpdateDialogData { installedItemId: string; diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html rename to ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts