diff --git a/ui/component/or-components/src/or-ace-editor.ts b/ui/component/or-components/src/or-ace-editor.ts index ddab993c97..773a6e5865 100644 --- a/ui/component/or-components/src/or-ace-editor.ts +++ b/ui/component/or-components/src/or-ace-editor.ts @@ -4,6 +4,7 @@ import ace, {Ace} from "ace-builds"; import "ace-builds/src-noconflict/mode-javascript"; import "ace-builds/src-noconflict/mode-json"; import "ace-builds/src-noconflict/mode-groovy"; +import "ace-builds/src-noconflict/mode-html"; import "ace-builds/webpack-resolver"; export class OrAceEditorChangedEvent extends CustomEvent<{ value: string, valid: boolean }> { diff --git a/ui/component/or-dashboard-builder/package.json b/ui/component/or-dashboard-builder/package.json index 137adb9826..58c6171140 100644 --- a/ui/component/or-dashboard-builder/package.json +++ b/ui/component/or-dashboard-builder/package.json @@ -21,6 +21,7 @@ "@openremote/or-attribute-report": "workspace:*", "@openremote/or-chart": "workspace:*", "@openremote/rest": "workspace:*", + "dompurify": "^3.2.4", "gridstack": "^7.2.0", "lit": "^2.0.2" }, diff --git a/ui/component/or-dashboard-builder/src/index.ts b/ui/component/or-dashboard-builder/src/index.ts index 6612746ca3..5c48d7a5da 100644 --- a/ui/component/or-dashboard-builder/src/index.ts +++ b/ui/component/or-dashboard-builder/src/index.ts @@ -31,6 +31,7 @@ import {MapWidget} from "./widgets/map-widget"; import {AttributeInputWidget} from "./widgets/attribute-input-widget"; import {TableWidget} from "./widgets/table-widget"; import {GatewayWidget} from "./widgets/gateway-widget"; +import {HtmlWidget} from "./widgets/html-widget"; import {ReportWidget} from "./widgets/report-widget"; // language=CSS @@ -219,6 +220,7 @@ export function registerWidgetTypes() { widgetTypes.set("attributeinput", AttributeInputWidget.getManifest()); widgetTypes.set("table", TableWidget.getManifest()); widgetTypes.set("gateway", GatewayWidget.getManifest()); + widgetTypes.set("html", HtmlWidget.getManifest()); widgetTypes.set("report", ReportWidget.getManifest()); } diff --git a/ui/component/or-dashboard-builder/src/widgets/html-widget.ts b/ui/component/or-dashboard-builder/src/widgets/html-widget.ts new file mode 100644 index 0000000000..a98870c0fe --- /dev/null +++ b/ui/component/or-dashboard-builder/src/widgets/html-widget.ts @@ -0,0 +1,231 @@ +import {css, html, PropertyValues, TemplateResult } from "lit"; +import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; +import {OrWidget} from "../util/or-widget"; +import {WidgetConfig} from "../util/widget-config"; +import {WidgetManifest} from "../util/or-widget"; +import {WidgetSettings} from "../util/widget-settings"; +import {OrMwcDialog, showDialog} from "@openremote/or-mwc-components/or-mwc-dialog"; +import { customElement, query} from "lit/decorators.js"; +import {createRef, Ref, ref } from "lit/directives/ref.js"; +import { InputType } from "@openremote/or-mwc-components/or-mwc-input"; +import {throttle} from "lodash"; +import {i18next} from "@openremote/or-translate"; +import {OrAceEditor} from "@openremote/or-components/or-ace-editor"; +import "ace-builds/src-noconflict/mode-html"; +import "ace-builds/webpack-resolver"; +import {showSnackbar} from "@openremote/or-mwc-components/or-mwc-snackbar"; +import DOMPurify from 'dompurify' + +export interface HtmlWidgetConfig extends WidgetConfig { + html: string, + sanitizerConfig: Object +} + +function getDefaultConfig(): HtmlWidgetConfig { + return { + html: '\n' + + '

     \n' + + '  OpenRemote \n' + + ' HTML widget

Paste your HTML, use any WYSIWYG editor for easy generation.

\n' + + '

\n' + + '

Write markup with HTML

', + sanitizerConfig: { + ALLOWED_TAGS: ['p', 'div', 'span', 'img', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br', 'h1', 'h2', 'h3'], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style', 'src'], + ALLOWED_STYLES: [ + 'color', 'font-size', 'text-align', 'margin', 'padding', + 'font-weight', 'font-style', 'text-decoration', 'background' + ], + ADD_TAGS: ['!doctype'], + ADD_ATTR: ['target'], // Allow target="_blank" for links + RETURN_DOM_FRAGMENT: false, + RETURN_DOM: false, + WHOLE_DOCUMENT: true, + RETURN_TRUSTED_TYPE: true + } + } +} + +const styling = css` + #widget-wrapper { + height: 100%; + justify-content: center; + align-items: center; + overflow: hidden; + } + + #error-txt { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + } + + .attr-input { + width: 100%; + box-sizing: border-box; + } + + .switch-container { + display: flex; + align-items: center; + justify-content: space-between; + } +` + +@customElement("html-widget") +export class HtmlWidget extends OrWidget { + + protected widgetConfig!: HtmlWidgetConfig; + + @query("#widget-wrapper") + protected widgetWrapperElem?: HTMLElement; + + protected resizeObserver?: ResizeObserver; + + static getManifest(): WidgetManifest { + return { + displayName: "HTML", + displayIcon: "language-html5", + getContentHtml(config: WidgetConfig): OrWidget { + return new HtmlWidget(config); + }, + getDefaultConfig(): WidgetConfig { + return getDefaultConfig(); + }, + getSettingsHtml(config: WidgetConfig): WidgetSettings { + return new HtmlSettings(config); + } + + } + } + + // TODO: Improve this to be more efficient + refreshContent(force: boolean): void { + this.widgetConfig = JSON.parse(JSON.stringify(this.widgetConfig)) as HtmlWidgetConfig; + } + + static get styles() { + return [...super.styles, styling]; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + delete this.resizeObserver; + } + + protected willUpdate(changedProps: PropertyValues) { + + // If widgetConfig, and the attributeRefs of them have changed... + if(changedProps.has("widgetConfig") && this.widgetConfig) { + } + + // Workaround for an issue with scalability of or-attribute-input when using 'display: flex'. + // The percentage slider doesn't scale properly, causing the dragging knob to glitch. + // Why? Because the Material Design element listens to a window resize, not a container resize. + // So we manually trigger this event when the attribute-input-widget changes in size. + if(!this.resizeObserver && this.widgetWrapperElem) { + this.resizeObserver = new ResizeObserver(throttle(() => { + window.dispatchEvent(new Event('resize')); + }, 200)); + this.resizeObserver.observe(this.widgetWrapperElem); + } + + return super.willUpdate(changedProps); + } + + + protected render(): TemplateResult { + const sanitizedContent = this.getSanitizedContent(); + return html` +
+
${unsafeHTML(sanitizedContent)}
+
+ `; + } + + // Sanitize the HTML content + private getSanitizedContent(): string { + return DOMPurify.sanitize(this.widgetConfig.html, this.widgetConfig.sanitizerConfig); + } +} + +@customElement("html-settings") +export class HtmlSettings extends WidgetSettings { + + protected readonly widgetConfig!: HtmlWidgetConfig; + + static get styles() { + return [...super.styles, styling]; + } + + protected render(): TemplateResult { + return html` +
+ + +
+ +
+
+ `; + } + + + + protected openHtmlInputDialog(content?: string) { + const editorRef: Ref = createRef(); + + showDialog(new OrMwcDialog() + .setHeading(i18next.t("HTML Editor")) + .setContent(()=> html ` +
+ +
+ `) + .setActions([ + {actionName: "cancel", content: "cancel"}, + {actionName: "save", content: "save", action: () => { + if (editorRef.value) { + if (!editorRef.value.validate()) { + console.warn("HMTL is not valid"); + showSnackbar(undefined, i18next.t('errorOccurred')); + } else if (editorRef.value.getValue()!.length > 0) { + this.widgetConfig.html = DOMPurify.sanitize(editorRef.value.getValue() ?? "", this.widgetConfig.sanitizerConfig) + if (DOMPurify.removed.length >= 1) { + console.warn("Purified Content:", DOMPurify.removed); + } + this.notifyConfigUpdate(); + } + } + } + }]) + .setStyles(html` + + `) + ) + } + + protected _getMode() { + return "ace/mode/html"; + } + +} diff --git a/yarn.lock b/yarn.lock index 895245ffbd..a9cc580ce4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2633,6 +2633,7 @@ __metadata: "@openremote/or-chart": "workspace:*" "@openremote/rest": "workspace:*" "@openremote/util": "workspace:*" + dompurify: "npm:^3.2.4" gridstack: "npm:^7.2.0" lit: "npm:^2.0.2" languageName: unknown @@ -3302,7 +3303,7 @@ __metadata: languageName: node linkType: hard -"@types/trusted-types@npm:^2.0.2": +"@types/trusted-types@npm:^2.0.2, @types/trusted-types@npm:^2.0.7": version: 2.0.7 resolution: "@types/trusted-types@npm:2.0.7" checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c @@ -4857,6 +4858,18 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.2.4": + version: 3.2.4 + resolution: "dompurify@npm:3.2.4" + dependencies: + "@types/trusted-types": "npm:^2.0.7" + dependenciesMeta: + "@types/trusted-types": + optional: true + checksum: 10c0/6be56810fb7ad2776155c8fc2967af5056783c030094362c7d0cf1ad13f2129cf922d8eefab528a34bdebfb98e2f44b306a983ab93aefb9d6f24c18a3d027a05 + languageName: node + linkType: hard + "domutils@npm:^2.5.2, domutils@npm:^2.8.0": version: 2.8.0 resolution: "domutils@npm:2.8.0"