Skip to content

Commit

Permalink
#3654: add image element to document builder
Browse files Browse the repository at this point in the history
  • Loading branch information
twschiller committed Jun 11, 2022
1 parent e6fd952 commit f2e0ef6
Show file tree
Hide file tree
Showing 14 changed files with 225 additions and 12 deletions.
24 changes: 24 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -70,6 +70,7 @@
"handlebars": "^4.7.7",
"hex-rgb": "^5.0.0",
"history": "^4.10.1",
"holderjs": "^2.9.9",
"htmlmetaparser": "^2.1.1",
"htmlparser2": "^8.0.0",
"http-status-codes": "^2.1.4",
Expand Down Expand Up @@ -183,6 +184,7 @@
"@types/gapi.client.oauth2": "^2.0.2",
"@types/gapi.client.sheets": "^4.0.20201029",
"@types/google.picker": "0.0.39",
"@types/holderjs": "^2.9.2",
"@types/intro.js": "^3.0.2",
"@types/jest": "^28.1.1",
"@types/jquery": "^3.5.6",
Expand Down
10 changes: 1 addition & 9 deletions src/blocks/renderers/propertyTable.tsx
Expand Up @@ -20,6 +20,7 @@ import { Renderer } from "@/types";
import { propertiesToSchema } from "@/validators/generic";
import { BlockArg, BlockOptions, SafeHTML } from "@/core";
import { sortBy, isPlainObject } from "lodash";
import { isValidUrl } from "@/utils";

interface Item {
key: string;
Expand All @@ -30,15 +31,6 @@ interface Item {
children: Item[];
}

function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

function richValue(value: unknown): unknown {
if (typeof value === "string" && isValidUrl(value)) {
return (
Expand Down
3 changes: 3 additions & 0 deletions src/components/documentBuilder/allowedElementTypes.ts
Expand Up @@ -26,6 +26,7 @@ export const ROOT_ELEMENT_TYPES: DocumentElementType[] = [
"header_2",
"header_3",
"text",
"image",
"container",
"card",
"pipeline",
Expand All @@ -49,6 +50,7 @@ const allowedChildTypes: Record<string, DocumentElementType[]> = {
"header_2",
"header_3",
"text",
"image",
"card",
"pipeline",
"button",
Expand All @@ -59,6 +61,7 @@ const allowedChildTypes: Record<string, DocumentElementType[]> = {
"header_2",
"header_3",
"text",
"image",
"container",
"pipeline",
"button",
Expand Down
4 changes: 4 additions & 0 deletions src/components/documentBuilder/createNewElement.ts
Expand Up @@ -35,6 +35,10 @@ export function createNewElement(elementType: DocumentElementType) {
element.config.text = "Paragraph text.";
break;

case "image":
element.config.url = null;
break;

case "container":
element.children = [createNewElement("row")];
break;
Expand Down
1 change: 1 addition & 0 deletions src/components/documentBuilder/documentBuilderTypes.ts
Expand Up @@ -25,6 +25,7 @@ export const DOCUMENT_ELEMENT_TYPES = [
"header_2",
"header_3",
"text",
"image",
"container",
"row",
"column",
Expand Down
8 changes: 7 additions & 1 deletion src/components/documentBuilder/documentTree.tsx
Expand Up @@ -20,7 +20,7 @@ import BlockElement from "@/components/documentBuilder/render/BlockElement";
import { isPipelineExpression } from "@/runtime/mapArgs";
import { UnknownObject } from "@/types";
import { get } from "lodash";
import { Card, Col, Container, Row } from "react-bootstrap";
import { Card, Col, Container, Row, Image } from "react-bootstrap";
import {
BuildDocumentBranch,
DocumentComponent,
Expand Down Expand Up @@ -75,6 +75,12 @@ export function getComponentDefinition(
return { Component: "p", props };
}

case "image": {
const { url, ...props } = config;
props.src = url;
return { Component: Image, props };
}

case "container":
case "row":
case "column": {
Expand Down
19 changes: 19 additions & 0 deletions src/components/documentBuilder/edit/getElementEditSchemas.ts
Expand Up @@ -52,6 +52,25 @@ function getElementEditSchemas(
return [textEdit, getClassNameEdit(elementName)];
}

case "image": {
const imageUrl: SchemaFieldProps = {
name: joinName(elementName, "config", "url"),
schema: { type: "string", format: "uri" },
label: "Image URL",
};
const height: SchemaFieldProps = {
name: joinName(elementName, "config", "height"),
schema: { type: ["string", "number"] },
label: "Height",
};
const width: SchemaFieldProps = {
name: joinName(elementName, "config", "width"),
schema: { type: ["string", "number"] },
label: "Width",
};
return [imageUrl, height, width, getClassNameEdit(elementName)];
}

case "card": {
const headingEdit: SchemaFieldProps = {
name: joinName(elementName, "config", "heading"),
Expand Down
1 change: 1 addition & 0 deletions src/components/documentBuilder/elementTypeLabels.ts
Expand Up @@ -26,6 +26,7 @@ const elementTypeLabels: Record<DocumentElementType, string> = {
column: "Column",
card: "Card",
text: "Text",
image: "Image",
button: "Button",
pipeline: "Brick",
list: "List",
Expand Down
Expand Up @@ -27,9 +27,12 @@ import { UnknownObject } from "@/types";
import { isExpression } from "@/runtime/mapArgs";
import cx from "classnames";
import React from "react";
import { Button } from "react-bootstrap";
import { Button, Image } from "react-bootstrap";
import { getComponentDefinition } from "@/components/documentBuilder/documentTree";
import elementTypeLabels from "@/components/documentBuilder/elementTypeLabels";
import { trySelectStringLiteral } from "@/runtime/expressionUtils";
import ImagePlaceholder from "@/components/imagePlaceholder/ImagePlaceholder";
import { isValidUrl } from "@/utils";

type PreviewComponentProps = {
className?: string;
Expand All @@ -52,6 +55,32 @@ function getPreviewComponentDefinition(
return getComponentDefinition(element);
}

case "image": {
const url = trySelectStringLiteral(element.config.url);
const height = trySelectStringLiteral(element.config.height);
const width = trySelectStringLiteral(element.config.width);

// If it's a valid URL, show the image
if (isValidUrl(url, { protocols: ["https:"] })) {
return {
Component: Image,
props: {
src: url,
height: height ?? 50,
width,
},
};
}

return {
Component: ImagePlaceholder,
props: {
height: height ?? 50,
width: width ?? 100,
},
};
}

case "container":
case "row":
case "column": {
Expand Down Expand Up @@ -161,8 +190,9 @@ function getPreviewComponentDefinition(
return { Component: PreviewComponent };
}

default:
default: {
return getComponentDefinition(element);
}
}
}

Expand Down
36 changes: 36 additions & 0 deletions src/components/imagePlaceholder/ImagePlaceholder.stories.tsx
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2022 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from "react";
import { ComponentStory, ComponentMeta } from "@storybook/react";

import ImagePlaceholder from "@/components/imagePlaceholder/ImagePlaceholder";

export default {
title: "Components/ImagePlaceholder",
component: ImagePlaceholder,
} as ComponentMeta<typeof ImagePlaceholder>;

const Template: ComponentStory<typeof ImagePlaceholder> = (args) => (
<ImagePlaceholder {...args} />
);

export const SquareImage = Template.bind({});
SquareImage.args = {
height: 50,
width: 50,
};
44 changes: 44 additions & 0 deletions src/components/imagePlaceholder/ImagePlaceholder.tsx
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2022 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useEffect, useRef } from "react";
import { run as runHolder } from "holderjs";
import { Image } from "react-bootstrap";

const ImagePlaceholder: React.VoidFunctionComponent<{
height: number | string;
width: number | string;
}> = ({ width, height }) => {
const imageRef = useRef();

useEffect(() => {
runHolder({
images: imageRef.current,
});
}, []);

// https://github.com/imsky/holder/issues/225#issuecomment-770261030
return (
<Image
alt="Placeholder"
ref={imageRef}
src={`holder.js/${height}x${width}`}
/>
);
};

export default ImagePlaceholder;
39 changes: 39 additions & 0 deletions src/runtime/expressionUtils.ts
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2022 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { isTemplateExpression } from "@/runtime/mapArgs";

function usesTemplateDirectives(value: string): boolean {
return value.includes("{{") || value.includes("{%");
}

export function trySelectStringLiteral(value: unknown): string | null {
if (value == null) {
return null;
}

if (typeof value === "string") {
// Already a string literal
return value;
}

if (isTemplateExpression(value) && !usesTemplateDirectives(value.__value__)) {
return value.__value__;
}

return null;
}

0 comments on commit f2e0ef6

Please sign in to comment.