diff --git a/package-lock.json b/package-lock.json index 9f7cc5101..4d1f15573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10142,6 +10142,11 @@ "version": "1.4.1", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.8.tgz", + "integrity": "sha512-b7uwreMYL2eZhrSCRC4ahLTeZcPZxSmYfmcQGXGkXiZSNW1X85v+SDM5KsWcpivIiUBH47Ji7NtyUdpLeF5JZQ==" + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -18296,13 +18301,6 @@ "url": "https://opencollective.com/preact" } }, - "node_modules/preact-markup": { - "version": "2.1.1", - "license": "MIT", - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -22149,6 +22147,7 @@ "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^10.0.1", + "dompurify": "^3.0.8", "feelers": "^1.2.0", "feelin": "^3.0.0", "flatpickr": "^4.6.13", @@ -22156,7 +22155,6 @@ "lodash": "^4.5.0", "min-dash": "^4.0.0", "preact": "^10.5.14", - "preact-markup": "^2.1.1", "showdown": "^2.1.0" } }, @@ -23722,6 +23720,7 @@ "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^10.0.1", + "dompurify": "^3.0.8", "feelers": "^1.2.0", "feelin": "^3.0.0", "flatpickr": "^4.6.13", @@ -23729,7 +23728,6 @@ "lodash": "^4.5.0", "min-dash": "^4.0.0", "preact": "^10.5.14", - "preact-markup": "^2.1.1", "showdown": "^2.1.0" }, "dependencies": { @@ -29347,6 +29345,11 @@ "domify": { "version": "1.4.1" }, + "dompurify": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.8.tgz", + "integrity": "sha512-b7uwreMYL2eZhrSCRC4ahLTeZcPZxSmYfmcQGXGkXiZSNW1X85v+SDM5KsWcpivIiUBH47Ji7NtyUdpLeF5JZQ==" + }, "dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -34965,10 +34968,6 @@ "preact": { "version": "10.5.14" }, - "preact-markup": { - "version": "2.1.1", - "requires": {} - }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/packages/form-js-carbon-styles/src/carbon-styles.scss b/packages/form-js-carbon-styles/src/carbon-styles.scss index bbfcf8adf..af8b39a08 100644 --- a/packages/form-js-carbon-styles/src/carbon-styles.scss +++ b/packages/form-js-carbon-styles/src/carbon-styles.scss @@ -165,7 +165,7 @@ // Markdown styles ///////////// -.fjs-container .fjs-form-field.fjs-form-field-text .markup { +.fjs-container .fjs-form-field.fjs-form-field-text { font-size: var(--cds-body-long-01-font-size); font-weight: var(--cds-body-long-01-font-weight); line-height: var(--cds-body-long-01-line-height); diff --git a/packages/form-js-editor/src/features/properties-panel/entries/HtmlEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/HtmlEntry.js new file mode 100644 index 000000000..ce545c92d --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/HtmlEntry.js @@ -0,0 +1,77 @@ +import { get } from 'min-dash'; + +import { useService, useVariables } from '../hooks'; + +import { FeelTemplatingEntry, isFeelEntryEdited } from '@bpmn-io/properties-panel'; + +import { useMemo } from 'preact/hooks'; + + +export function HtmlEntry(props) { + const { + editField, + field + } = props; + + const entries = [ + { + id: 'content', + component: Content, + editField: editField, + field: field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => field.type === 'html' + } + ]; + + return entries; +} + +function Content(props) { + const { + editField, + field, + id + } = props; + + const debounce = useService('debounce'); + + const variables = useVariables().map(name => ({ name })); + + const path = [ 'content' ]; + + const getValue = () => { + return get(field, path, ''); + }; + + const setValue = (value) => { + return editField(field, path, value || ''); + }; + + const validate = (value) => { + + // allow empty state + if (value === undefined || value === null || value === '') { return null; } + + // allow expressions + if (value.startsWith('=')) { return null; } + + // disallow style tags + if (value.includes(' <>Supports HTML, inline styling, and templating. Learn more, []); + + return FeelTemplatingEntry({ + debounce, + description, + element: field, + getValue, + id, + label: 'Content', + hostLanguage: 'html', + validate, + setValue, + variables + }); +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/entries/index.js b/packages/form-js-editor/src/features/properties-panel/entries/index.js index 23c812bf3..f3768c559 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/index.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/index.js @@ -13,6 +13,7 @@ export { IFrameHeightEntry } from './IFrameHeightEntry'; export { IFrameUrlEntry } from './IFrameUrlEntry'; export { ImageSourceEntry } from './ImageSourceEntry'; export { TextEntry } from './TextEntry'; +export { HtmlEntry } from './HtmlEntry'; export { HeightEntry } from './HeightEntry'; export { NumberEntries } from './NumberEntries'; export { NumberSerializationEntry } from './NumberSerializationEntry'; diff --git a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js index 2886f8dd0..fdb3999bf 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js @@ -15,6 +15,7 @@ import { ReadonlyEntry, SelectEntries, TextEntry, + HtmlEntry, HeightEntry, NumberEntries, DateTimeEntry, @@ -37,6 +38,7 @@ export function GeneralGroup(field, editField, getService) { ...ActionEntry({ field, editField }), ...DateTimeEntry({ field, editField }), ...TextEntry({ field, editField, getService }), + ...HtmlEntry({ field, editField, getService }), ...IFrameUrlEntry({ field, editField }), ...IFrameHeightEntry({ field, editField }), ...HeightEntry({ field, editField }), diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/EditorHtml.js b/packages/form-js-editor/src/render/components/editor-form-fields/EditorHtml.js new file mode 100644 index 000000000..6dc26e7d0 --- /dev/null +++ b/packages/form-js-editor/src/render/components/editor-form-fields/EditorHtml.js @@ -0,0 +1,37 @@ +import { Html } from '@bpmn-io/form-js-viewer'; +import { editorFormFieldClasses } from '../Util'; +import { useService } from '../../hooks'; + +import { iconsByType } from '../icons'; + +export function EditorHtml(props) { + + const { type, content = '' } = props.field; + + const Icon = iconsByType(type); + + const templating = useService('templating'); + const expressionLanguage = useService('expressionLanguage'); + + if (!content || !content.trim()) { + return
+
Html is empty
+
; + } + + if (expressionLanguage.isExpression(content)) { + return
+
Html is populated by an expression
+
; + } + + if (templating.isTemplate(content)) { + return
+
Html is templated
+
; + } + + return ; +} + +EditorHtml.config = Html.config; diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/index.js b/packages/form-js-editor/src/render/components/editor-form-fields/index.js index b196e7a8a..126ed795c 100644 --- a/packages/form-js-editor/src/render/components/editor-form-fields/index.js +++ b/packages/form-js-editor/src/render/components/editor-form-fields/index.js @@ -1,9 +1,11 @@ import { EditorIFrame } from './EditorIFrame'; import { EditorText } from './EditorText'; +import { EditorHtml } from './EditorHtml'; import { EditorTable } from './EditorTable'; export const editorFormFields = [ EditorIFrame, EditorText, + EditorHtml, EditorTable ]; \ No newline at end of file diff --git a/packages/form-js-viewer/package.json b/packages/form-js-viewer/package.json index c3bb846f8..f786ec2fe 100644 --- a/packages/form-js-viewer/package.json +++ b/packages/form-js-viewer/package.json @@ -48,6 +48,7 @@ "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^10.0.1", + "dompurify": "^3.0.8", "feelers": "^1.2.0", "feelin": "^3.0.0", "flatpickr": "^4.6.13", @@ -55,7 +56,6 @@ "lodash": "^4.5.0", "min-dash": "^4.0.0", "preact": "^10.5.14", - "preact-markup": "^2.1.1", "showdown": "^2.1.0" }, "sideEffects": [ diff --git a/packages/form-js-viewer/rollup.config.js b/packages/form-js-viewer/rollup.config.js index 7dbb50fc0..3a048a25a 100644 --- a/packages/form-js-viewer/rollup.config.js +++ b/packages/form-js-viewer/rollup.config.js @@ -55,7 +55,6 @@ export default [ 'preact/jsx-runtime', 'preact/hooks', 'preact/compat', - 'preact-markup', 'flatpickr', 'showdown', '@carbon/grid', diff --git a/packages/form-js-viewer/src/render/components/Sanitizer.js b/packages/form-js-viewer/src/render/components/Sanitizer.js index a62e8a898..b26f397e4 100644 --- a/packages/form-js-viewer/src/render/components/Sanitizer.js +++ b/packages/form-js-viewer/src/render/components/Sanitizer.js @@ -70,9 +70,10 @@ export function sanitizeHTML(html) { const element = doc.body.firstChild; if (element) { + sanitizeNode(/** @type Element */ (element)); + return /** @type Element */ (element).innerHTML; - return new XMLSerializer().serializeToString(element); } else { // handle the case that document parsing diff --git a/packages/form-js-viewer/src/render/components/form-fields/Html.js b/packages/form-js-viewer/src/render/components/form-fields/Html.js new file mode 100644 index 000000000..06544375b --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/Html.js @@ -0,0 +1,60 @@ +import { useCallback } from 'preact/hooks'; +import { useService, useTemplateEvaluation } from '../../hooks'; +import { RawHTMLRenderer } from './parts/RawHTMLRenderer'; + +import { + formFieldClasses +} from '../Util'; + +const type = 'html'; + +export function Html(props) { + + const form = useService('form'); + const { textLinkTarget } = form._getState().properties; + + const { field, disableLinks } = props; + + const { content = '', strict = false } = field; + + const html = useTemplateEvaluation(content, { debug: true, strict }); + + const transformLinks = useCallback((html) => { + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const links = tempDiv.querySelectorAll('a'); + + links.forEach(link => { + + if (disableLinks) { + link.setAttribute('class', 'fjs-disabled-link'); + link.setAttribute('tabIndex', '-1'); + } + + if (textLinkTarget) { + link.setAttribute('target', textLinkTarget); + } + + }); + + return tempDiv.innerHTML; + + }, [ disableLinks, textLinkTarget ]); + + return
+ +
; +} + +Html.config = { + type, + keyed: false, + label: 'HTML', + group: 'presentation', + create: (options = {}) => ({ + content: '', + ...options + }) +}; diff --git a/packages/form-js-viewer/src/render/components/form-fields/Text.js b/packages/form-js-viewer/src/render/components/form-fields/Text.js index ecb18d921..f3be6d9ca 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Text.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Text.js @@ -1,7 +1,7 @@ -import Markup from 'preact-markup'; -import { useMemo } from 'preact/hooks'; +import { useCallback, useMemo } from 'preact/hooks'; import { useService, useTemplateEvaluation } from '../../hooks'; import { sanitizeHTML } from '../Sanitizer'; +import { RawHTMLRenderer } from './parts/RawHTMLRenderer'; import { formFieldClasses @@ -23,29 +23,37 @@ export function Text(props) { // feelers => pure markdown const markdown = useTemplateEvaluation(text, { debug: true, strict }); - // markdown => safe HTML - const safeHtml = useMemo(() => { - const html = markdownRenderer.render(markdown); - return sanitizeHTML(html); - }, [ markdownRenderer, markdown ]); + // markdown => html + const html = useMemo(() => markdownRenderer.render(markdown), [ markdownRenderer, markdown ]); - const OverriddenTargetLink = useMemo(() => BuildOverriddenTargetLink(textLinkTarget), [ textLinkTarget ]); + const sanitizeAndTransformLinks = useCallback((unsafeHtml) => { - const componentOverrides = useMemo(() => { + const html = sanitizeHTML(unsafeHtml); - if (disableLinks) { - return { 'a': DisabledLink }; - } + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; - if (textLinkTarget) { - return { 'a': OverriddenTargetLink }; - } + const links = tempDiv.querySelectorAll('a'); - return {}; - }, [ disableLinks, OverriddenTargetLink, textLinkTarget ]); + links.forEach(link => { + + if (disableLinks) { + link.setAttribute('class', 'fjs-disabled-link'); + link.setAttribute('tabIndex', '-1'); + } + + if (textLinkTarget) { + link.setAttribute('target', textLinkTarget); + } + + }); + + return tempDiv.innerHTML; + + }, [ disableLinks, textLinkTarget ]); return
- +
; } @@ -59,11 +67,3 @@ Text.config = { ...options }) }; - -function BuildOverriddenTargetLink(target) { - return function({ children, ...rest }) { - return { children }; - }; -} - -function DisabledLink({ children, ...rest }) { return { children }; } diff --git a/packages/form-js-viewer/src/render/components/form-fields/parts/RawHTMLRenderer.js b/packages/form-js-viewer/src/render/components/form-fields/parts/RawHTMLRenderer.js new file mode 100644 index 000000000..91b3ac247 --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/parts/RawHTMLRenderer.js @@ -0,0 +1,16 @@ +import DOMPurify from 'dompurify'; +import { useMemo } from 'preact/hooks'; + +export const RawHTMLRenderer = ({ html, transform = (html) => html, sanitize = true, sanitizeStyleTags = true }) => { + + const sanitizeHtml = (htmlContent) => { + if (!sanitize) return htmlContent; + const config = sanitizeStyleTags ? { FORBID_TAGS: [ 'style' ] } : {}; + return DOMPurify.sanitize(htmlContent, config); + }; + + const sanitizedHtml = sanitizeHtml(html); + const tranformedHtml = useMemo(() => transform(sanitizedHtml), [ sanitizedHtml, transform ]); + + return
; +}; \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/icons/HTML.svg b/packages/form-js-viewer/src/render/components/icons/HTML.svg new file mode 100644 index 000000000..fb9eeeafe --- /dev/null +++ b/packages/form-js-viewer/src/render/components/icons/HTML.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/form-js-viewer/src/render/components/icons/index.js b/packages/form-js-viewer/src/render/components/icons/index.js index b651a6f81..1523c6a1a 100644 --- a/packages/form-js-viewer/src/render/components/icons/index.js +++ b/packages/form-js-viewer/src/render/components/icons/index.js @@ -12,6 +12,7 @@ import SeparatorIcon from './Separator.svg'; import SpacerIcon from './Spacer.svg'; import DynamicListIcon from './DynamicList.svg'; import TextIcon from './Text.svg'; +import HTMLIcon from './HTML.svg'; import TextfieldIcon from './Textfield.svg'; import TextareaIcon from './Textarea.svg'; import IFrameIcon from './IFrame.svg'; @@ -37,6 +38,7 @@ export const iconsByType = (type) => { dynamiclist: DynamicListIcon, taglist: TaglistIcon, text: TextIcon, + html: HTMLIcon, textfield: TextfieldIcon, textarea: TextareaIcon, table: TableIcon, diff --git a/packages/form-js-viewer/src/render/components/index.js b/packages/form-js-viewer/src/render/components/index.js index d2fa499ba..daaab6235 100644 --- a/packages/form-js-viewer/src/render/components/index.js +++ b/packages/form-js-viewer/src/render/components/index.js @@ -14,6 +14,7 @@ import { Spacer } from './form-fields/Spacer'; import { DynamicList } from './form-fields/DynamicList'; import { Taglist } from './form-fields/Taglist'; import { Text } from './form-fields/Text'; +import { Html } from './form-fields/Html'; import { Textfield } from './form-fields/Textfield'; import { Textarea } from './form-fields/Textarea'; import { Table } from './form-fields/Table'; @@ -49,6 +50,7 @@ export { Spacer, Taglist, Text, + Html, Textfield, Textarea, Table @@ -59,22 +61,23 @@ export const formFields = [ Checkbox, Checklist, Default, - Group, - IFrame, DynamicList, - Image, Numberfield, Datetime, Radio, Select, - Spacer, - Separator, - DynamicList, Taglist, - Text, Textfield, Textarea, - Table + Text, + Image, + Table, + Html, + Spacer, + Separator, + Group, + DynamicList, + IFrame ]; export * from './icons'; diff --git a/packages/form-js-viewer/test/spec/render/components/Sanitizer.spec.js b/packages/form-js-viewer/test/spec/render/components/Sanitizer.spec.js index 6dd96a3c3..263a56872 100644 --- a/packages/form-js-viewer/test/spec/render/components/Sanitizer.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/Sanitizer.spec.js @@ -17,7 +17,7 @@ describe('Sanitizer', function() { const sanitized = sanitizeHTML(html); // then - expect(sanitized).to.equal('
'); + expect(sanitized).to.equal(''); }); @@ -30,7 +30,7 @@ describe('Sanitizer', function() { const sanitized = sanitizeHTML(html); // then - expect(sanitized).to.equal('

test

'); + expect(sanitized).to.equal('

test

'); }); @@ -43,7 +43,7 @@ describe('Sanitizer', function() { const sanitized = sanitizeHTML(html); // then - expect(sanitized).to.equal('

foo

'); + expect(sanitized).to.equal('

foo

'); }); }); diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/Html.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/Html.spec.js new file mode 100644 index 000000000..69f4195b3 --- /dev/null +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/Html.spec.js @@ -0,0 +1,152 @@ +import { render } from '@testing-library/preact/pure'; + +import { Html } from '../../../../../src/render/components/form-fields/Html'; + +import { + createFormContainer, +} from '../../../../TestHelper'; + +import { MockFormContext } from '../helper'; + +let container; + +describe('Html', function() { + + beforeEach(function() { + container = createFormContainer(); + }); + + afterEach(function() { + container.remove(); + }); + + + it('should render', function() { + + // when + const { container } = createHtmlComponent(); + + // then + const formField = container.querySelector('.fjs-form-field'); + expect(formField).to.exist; + expect(formField.innerHTML).to.eql(`
${defaultField.content}
`); + }); + + + it('should render HTML content with inline styles', function() { + + // given + const content = '

Some styled content

'; + + // when + const { container } = createHtmlComponent({ + field: { ...defaultField, content } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + expect(formField).to.exist; + expect(formField.innerHTML).to.eql(`
${content}
`); + }); + + + it('should ignore style tags', function() { + + // given + const content = ` + +
Content with class style
+ `; + + // when + const { container } = createHtmlComponent({ + field: { ...defaultField, content } + }); + + // then + const formField = container.querySelector('.fjs-form-field'); + expect(formField).to.exist; + expect(formField.innerHTML).to.include('Content with class style'); + + const styledDiv = formField.querySelector('.test-style'); + expect(styledDiv).to.exist; + + // Checking that the style defined in