From 7893ec4cf75b8c7d6632f890c787e524db97246c Mon Sep 17 00:00:00 2001 From: Balint Gabor <127662+gbalint@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:42:00 +0100 Subject: [PATCH] Html control with preview (#4908) * Html control with preview * Add back commented out test code --- .../component-section.spec.browser2.tsx | 163 +++++++++++++++++- .../component-section/component-section.tsx | 3 + .../property-content-preview.tsx | 18 +- .../property-control-controls.tsx | 39 ++++- .../property-control-values.ts | 6 + .../property-controls-parser.ts | 26 +++ utopia-api/src/property-controls/factories.ts | 15 +- .../property-controls/property-controls.ts | 11 ++ 8 files changed, 255 insertions(+), 26 deletions(-) diff --git a/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx b/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx index 9450def833c5..d8fc3bb3fa4b 100644 --- a/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx +++ b/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx @@ -1,6 +1,6 @@ import { within } from '@testing-library/react' import * as EP from '../../../../core/shared/element-path' -import { selectComponentsForTest } from '../../../../utils/utils.test-utils' +import { selectComponentsForTest, wait } from '../../../../utils/utils.test-utils' import { mouseClickAtPoint, pressKey } from '../../../canvas/event-helpers.test-utils' import { renderTestEditorWithCode } from '../../../canvas/ui-jsx.test-utils' import { @@ -43,31 +43,31 @@ describe('Set element prop via the data picker', () => { let currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(0)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Title too')).not.toBeNull() - expect(within(theInspector).queryAllByText('Title too')).toHaveLength(2) + expect(within(theInspector).queryByText('Title too')).not.toBeNull() // choose another string-valued variable currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(1)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Alternate title')).not.toBeNull() - expect(within(theInspector).queryAllByText('Alternate title')).toHaveLength(2) + expect(within(theInspector).queryByText('Alternate title')).not.toBeNull() // choose an object prop currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(3)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('The First Title')).not.toBeNull() - expect(within(theInspector).queryAllByText('The First Title')).toHaveLength(2) + expect(within(theInspector).queryByText('The First Title')).not.toBeNull() // choose an object prop currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(4)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Sweet')).not.toBeNull() - expect(within(theInspector).queryAllByText('Sweet')).toHaveLength(2) + expect(within(theInspector).queryByText('Sweet')).not.toBeNull() // choose an array element currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(6)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Chapter One')).not.toBeNull() - expect(within(theInspector).queryAllByText('Chapter One')).toHaveLength(2) + expect(within(theInspector).queryByText('Chapter One')).not.toBeNull() }) it('with number input control descriptor present', async () => { @@ -442,6 +442,17 @@ describe('Controls from registering components', () => { expect(within(theScene).queryByText('New title')).not.toBeNull() }) + it('registering internal component with html prop shows preview', async () => { + const editor = await renderTestEditorWithCode( + registerInternalComponentProjectWithHtmlProp, + 'await-first-dom-report', + ) + await selectComponentsForTest(editor, [EP.fromString('sb/scene/pg:root/title')]) + + const theInspector = editor.renderedDOM.getByTestId('inspector-sections-container') + expect(within(theInspector).queryByText('Hello Utopia')).not.toBeNull() + }) + it('registering external component', async () => { const editor = await renderTestEditorWithCode( registerExternalComponentProject, @@ -772,6 +783,144 @@ export var storyboard = ( )` } +const projectWithHtmlProp = (imageUrl: string) => `import * as React from 'react' +import { + Storyboard, + Scene, + registerInternalComponent, +} from 'utopia-api' + +function Image({ url }) { + return +} + +var Playground = ({ style }) => { + return ( +
+ +
+ ) +} + +export var storyboard = ( + + + + + +) + +registerInternalComponent(Image, { + supportsChildren: false, + properties: { + url: { + control: 'string-input', + }, + }, + variants: [ + { + code: '', + }, + ], +}) + +` + +const registerInternalComponentProjectWithHtmlProp = `import * as React from 'react' +import { + Storyboard, + Scene, + registerInternalComponent, +} from 'utopia-api' + +function Title({ text }) { + return

{text}

+} + +var Playground = ({ style }) => { + return ( +
+ + </div> + ) +} + +export var storyboard = ( + <Storyboard data-uid='sb'> + <Scene + style={{ + width: 521, + height: 266, + position: 'absolute', + left: 554, + top: 247, + backgroundColor: 'white', + }} + data-uid='scene' + data-testid='scene' + commentId='120' + > + <Playground + style={{ + width: 454, + height: 177, + position: 'absolute', + left: 34, + top: 44, + backgroundColor: 'white', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + title='Hello Utopia' + data-uid='pg' + /> + </Scene> + </Storyboard> +) + +registerInternalComponent(Title, { + supportsChildren: false, + properties: { + text: { + control: 'html-input', + }, + }, + variants: [ + { + code: '<Title />', + }, + ], +}) + +` + // const projectWithImage = (imageUrl: string) => `import * as React from 'react' // import { // Storyboard, @@ -838,5 +987,3 @@ export var storyboard = ( // }, // ], // }) - -// ` diff --git a/editor/src/components/inspector/sections/component-section/component-section.tsx b/editor/src/components/inspector/sections/component-section/component-section.tsx index e9c0cfa14120..57f27f4c63cc 100644 --- a/editor/src/components/inspector/sections/component-section/component-section.tsx +++ b/editor/src/components/inspector/sections/component-section/component-section.tsx @@ -78,6 +78,7 @@ import { ExpressionInputPropertyControl, StringInputPropertyControl, VectorPropertyControl, + HtmlInputPropertyControl, } from './property-control-controls' import { ExpandableIndicator } from '../../../navigator/navigator-item/expandable-indicator' import { unless, when } from '../../../../utils/react-conditionals' @@ -147,6 +148,8 @@ const ControlForProp = React.memo((props: ControlForPropProps<BaseControlDescrip return <RadioPropertyControl {...props} controlDescription={controlDescription} /> case 'string-input': return <StringInputPropertyControl {...props} controlDescription={controlDescription} /> + case 'html-input': + return <HtmlInputPropertyControl {...props} controlDescription={controlDescription} /> case 'style-controls': return null case 'vector2': diff --git a/editor/src/components/inspector/sections/component-section/property-content-preview.tsx b/editor/src/components/inspector/sections/component-section/property-content-preview.tsx index 9aaa499ddd46..e68d0604b2d9 100644 --- a/editor/src/components/inspector/sections/component-section/property-content-preview.tsx +++ b/editor/src/components/inspector/sections/component-section/property-content-preview.tsx @@ -3,23 +3,11 @@ import sanitizeHtml from 'sanitize-html' import { isImage } from '../../../../core/shared/utils' export const ImagePreviewTestId = 'image-preview' -interface ContentPreviewProps { - text: string -} -export const ContentPreview = React.memo(({ text }: ContentPreviewProps) => { - if (isImage(text)) { - return <ImagePreview url={text} /> - } - // maybe later we can check more about whether this is an html or not - return <HtmlPreview html={text} /> -}) -ContentPreview.displayName = 'ContentPreview' - interface ImagePreviewProps { url: string } -const ImagePreview = React.memo(({ url }: ImagePreviewProps) => { - const [imageCanBeLoaded, setImageCanBeLoaded] = React.useState(true) +export const ImagePreview = React.memo(({ url }: ImagePreviewProps) => { + const [imageCanBeLoaded, setImageCanBeLoaded] = React.useState(isImage(url)) // we need to track if the url has changed so we retry loading the image even if it failed before const urlRef = React.useRef<string>(url) @@ -52,7 +40,7 @@ interface HtmlPreviewProps { html: string } -const HtmlPreview = React.memo(({ html }: HtmlPreviewProps) => { +export const HtmlPreview = React.memo(({ html }: HtmlPreviewProps) => { const sanitizedHtml = sanitizeHtml(html) return ( <div diff --git a/editor/src/components/inspector/sections/component-section/property-control-controls.tsx b/editor/src/components/inspector/sections/component-section/property-control-controls.tsx index 6b23f2b38573..adc85bfab7db 100644 --- a/editor/src/components/inspector/sections/component-section/property-control-controls.tsx +++ b/editor/src/components/inspector/sections/component-section/property-control-controls.tsx @@ -11,6 +11,7 @@ import type { EulerControlDescription, ExpressionInputControlDescription, ExpressionPopUpListControlDescription, + HtmlInputControlDescription, Matrix3ControlDescription, Matrix4ControlDescription, NumberInputControlDescription, @@ -55,7 +56,7 @@ import { normalisePathToUnderlyingTarget, } from '../../../custom-code/code-file' import { useDispatch } from '../../../editor/store/dispatch-context' -import { ContentPreview } from './property-content-preview' +import { HtmlPreview, ImagePreview } from './property-content-preview' export interface ControlForPropProps<T extends BaseControlDescription> { propPath: PropertyPath @@ -466,7 +467,41 @@ export const StringInputPropertyControl = React.memo( controlStyles={propMetadata.controlStyles} focus={props.focusOnMount} /> - <ContentPreview text={safeValue} /> + <ImagePreview url={safeValue} /> + </div> + ) + }, +) + +export const HtmlInputPropertyControl = React.memo( + (props: ControlForPropProps<HtmlInputControlDescription>) => { + const { propName, propMetadata, controlDescription } = props + + const controlId = `${propName}-string-input-property-control` + const value = propMetadata.propertyStatus.set ? propMetadata.value : undefined + + const safeValue = value ?? '' + + return ( + <div + style={{ + display: 'flex', + flexDirection: 'column', + flexBasis: 0, + gap: 5, + }} + > + <StringControl + key={controlId} + id={controlId} + testId={controlId} + value={safeValue} + onSubmitValue={propMetadata.onSubmitValue} + controlStatus={propMetadata.controlStatus} + controlStyles={propMetadata.controlStyles} + focus={props.focusOnMount} + /> + <HtmlPreview html={safeValue} /> </div> ) }, diff --git a/editor/src/core/property-controls/property-control-values.ts b/editor/src/core/property-controls/property-control-values.ts index 7c45de050e5d..6bd22d34dca7 100644 --- a/editor/src/core/property-controls/property-control-values.ts +++ b/editor/src/core/property-controls/property-control-values.ts @@ -302,6 +302,8 @@ export function unwrapperAndParserForBaseControl( return defaultUnwrapFirst(parseAny) case 'string-input': return defaultUnwrapFirst(parseString) + case 'html-input': + return defaultUnwrapFirst(parseString) case 'style-controls': return defaultUnwrapFirst(parseAny) case 'vector2': @@ -330,6 +332,7 @@ export function unwrapperAndParserForPropertyControl( case 'popuplist': case 'radio': case 'string-input': + case 'html-input': case 'style-controls': case 'vector2': case 'vector3': @@ -454,6 +457,8 @@ export function printerForBasePropertyControl(control: BaseControlDescription): return printSimple case 'string-input': return printSimple + case 'html-input': + return printSimple case 'style-controls': return printSimple case 'vector2': @@ -535,6 +540,7 @@ export function printerForPropertyControl(control: RegularControlDescription): P case 'popuplist': case 'radio': case 'string-input': + case 'html-input': case 'style-controls': case 'vector2': case 'vector3': diff --git a/editor/src/core/property-controls/property-controls-parser.ts b/editor/src/core/property-controls/property-controls-parser.ts index a4fd92c9b6c1..85ec7b8b20b8 100644 --- a/editor/src/core/property-controls/property-controls-parser.ts +++ b/editor/src/core/property-controls/property-controls-parser.ts @@ -28,6 +28,7 @@ import type { BasicControlOptions, ExpressionControlOption, TupleControlDescription, + HtmlInputControlDescription, } from 'utopia-api/core' import { parseColor } from '../../components/inspector/common/css-utils' import type { Parser, ParseResult } from '../../utils/value-parser-utils' @@ -257,6 +258,29 @@ export function parseStringInputControlDescription( ) } +export function parseHtmlInputControlDescription( + value: unknown, +): ParseResult<HtmlInputControlDescription> { + return applicative5Either( + (label, control, placeholder, obscured, visibleByDefault) => { + let htmlInputControlDescription: HtmlInputControlDescription = { + control: control, + } + setOptionalProp(htmlInputControlDescription, 'label', label) + setOptionalProp(htmlInputControlDescription, 'placeholder', placeholder) + setOptionalProp(htmlInputControlDescription, 'obscured', obscured) + setOptionalProp(htmlInputControlDescription, 'visibleByDefault', visibleByDefault) + + return htmlInputControlDescription + }, + optionalObjectKeyParser(parseString, 'label')(value), + objectKeyParser(parseConstant('html-input'), 'control')(value), + optionalObjectKeyParser(parseString, 'placeholder')(value), + optionalObjectKeyParser(parseBoolean, 'obscured')(value), + optionalObjectKeyParser(parseBoolean, 'visibleByDefault')(value), + ) +} + export function parseRadioControlDescription(value: unknown): ParseResult<RadioControlDescription> { return applicative4Either( (label, control, options, visibleByDefault) => { @@ -657,6 +681,8 @@ function parseRegularControlDescription(value: unknown): ParseResult<RegularCont return parsePopUpListControlDescription(value) case 'string-input': return parseStringInputControlDescription(value) + case 'html-input': + return parseHtmlInputControlDescription(value) case 'style-controls': return parseStyleControlsControlDescription(value) case 'tuple': diff --git a/utopia-api/src/property-controls/factories.ts b/utopia-api/src/property-controls/factories.ts index fde920eaa44b..da06522ba1dd 100644 --- a/utopia-api/src/property-controls/factories.ts +++ b/utopia-api/src/property-controls/factories.ts @@ -1,4 +1,4 @@ -import { +import type { ArrayControlDescription, BasicControlOptions, CheckboxControlDescription, @@ -8,6 +8,7 @@ import { ExpressionInputControlDescription, ExpressionPopUpListControlDescription, FolderControlDescription, + HtmlInputControlDescription, ImportType, Matrix3ControlDescription, Matrix4ControlDescription, @@ -177,6 +178,18 @@ export function stringControl(placeholder?: string): StringInputControlDescripti return result } +export function htmlControl(placeholder?: string): HtmlInputControlDescription { + let result: HtmlInputControlDescription = { + control: 'html-input', + } + + if (placeholder !== undefined) { + result.placeholder = placeholder + } + + return result +} + export function styleControl(): StyleControlsControlDescription { return { control: 'style-controls', diff --git a/utopia-api/src/property-controls/property-controls.ts b/utopia-api/src/property-controls/property-controls.ts index bfe84c341df8..0b049be33046 100644 --- a/utopia-api/src/property-controls/property-controls.ts +++ b/utopia-api/src/property-controls/property-controls.ts @@ -30,6 +30,7 @@ export type BaseControlType = | 'popuplist' | 'radio' | 'string-input' + | 'html-input' | 'style-controls' | 'vector2' | 'vector3' @@ -140,6 +141,14 @@ export interface StringInputControlDescription { obscured?: boolean } +export interface HtmlInputControlDescription { + control: 'html-input' + label?: string + visibleByDefault?: boolean + placeholder?: string + obscured?: boolean +} + export interface StyleControlsControlDescription { control: 'style-controls' label?: string @@ -178,6 +187,7 @@ export type BaseControlDescription = | RadioControlDescription | PopUpListControlDescription | StringInputControlDescription + | HtmlInputControlDescription | StyleControlsControlDescription | Vector2ControlDescription | Vector3ControlDescription @@ -251,6 +261,7 @@ export function isBaseControlDescription( case 'popuplist': case 'radio': case 'string-input': + case 'html-input': case 'style-controls': case 'vector2': case 'vector3':