Creating app elements

How to create app elements.

Once an app adds elements to a user's design, such as images or videos, the app can't edit those elements — it's as if they become invisible to the app.

Sometimes, this can be limiting.

For example, imagine an app that creates gradients. If the app creates the gradients as images, the user can't change the gradient once it exists. They can only create new gradients. To update a gradient:

  • The user has to delete the previous gradient from their design.
  • A new image has to be uploaded to the user's media library.

This is a sub-par user experience that app elements are designed to solve.

An app element is a type of element that apps can modify after the element exists in the user's design. They have limitations and a more complex lifecycle, so it doesn't always make sense to use them, but if you're otherwise unable to create the app you want, app elements may be the answer.

Behind the scenes, app elements are groups of elements that:

  • Can't be un-grouped — that is, they're locked groups
  • Can have metadata attached to them

Like groups, app elements can be made up of multiple child elements, but unlike groups, app elements are allowed to contain a single element.

By attaching metadata to an element, the element's settings — for example, the colors of a gradient — can be persisted on the element itself. The app can update the metadata, causing the element to re-render. The end result is that the elements can be edited by the app that created them.

Create a type that represents data required to render the element. For example, for an app element to render a gradient, an appropriate type would need to hold at least two colors:

type AppElementData = {
color1: string;
color2: string;
};
ts

The name of the type is not important.

Import the initAppElement method from the @canva/design package:

import { initAppElement } from "@canva/design";
ts

Then call the method outside of a React component:

const appElementClient = initAppElement<AppElementData>({
render: (data) => {
const dataUrl = createGradient(data.color1, data.color2);
return [
{
type: "IMAGE",
dataUrl,
width: 640,
height: 360,
top: 0,
left: 0,
},
];
},
});
ts

There's a few things going on here, so let's break it down:

  • The initAppElement method should be called outside of a React component because the rendering of the element is not tied to the rendering of the component.
  • The type for the app element data is passed to the initAppElement method as a type argument. This ensures that we have accurate type information while working with the method.
  • The initAppElement method accepts an object as its only parameter. This object requires a render function that determines the elements to render in the app element. It receives the app element data as its only argument and must return one or more elements.
  • The returned elements must have positional properties, including coordinates and dimensions. To learn more about these options, see Positioning elements.
  • The render method should only rely on data that's passed in via the data parameter. Given the same data, it should return the same result.

In this particular example, the data is passed into the following createGradient function that returns a data URL for a gradient:

function createGradient(color1: string, color2: string): string {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Can't get CanvasRenderingContext2D");
}
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL();
}
ts

To render (or re-render) an app element, call the addOrUpdateElement method:

appElementClient.addOrUpdateElement({
color1: "",
color2: "",
});
ts

This method accepts an object that conforms to the structure of the app element's data.

If an app element isn't selected, one will be created and added to the user's design. If an app element is selected, its data will be updated and the element will be re-rendered.

The following code demonstrates how an app might allow the user to customize the values and then render the app element when the user clicks a button:

export function App() {
const [state, setState] = React.useState({
color1: "",
color2: "",
});
function handleClick() {
appElementClient.addOrUpdateElement({
color1: state.color1,
color2: state.color2,
});
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setState((prevState) => {
return {
...prevState,
[event.target.name]: event.target.value,
};
});
}
return (
<div>
<div>
<input
type="text"
name="color1"
value={state.color1}
placeholder="Color #1"
onChange={handleChange}
/>
</div>
<div>
<input
type="text"
name="color2"
value={state.color2}
placeholder="Color #2"
onChange={handleChange}
/>
</div>
<button type="submit" onClick={handleClick}>
Add or update element
</button>
</div>
);
}
tsx

In the previous code sample, we have this Add or update element button:

<button type="submit" onClick={handleClick}>
Add or update element
</button>
tsx

This isn't the ideal user experience. It'd be nicer if we showed an Add element button when an element isn't selected and an Update element button when an element is selected.

To do this, create an isSelected property in the useState hook:

const [state, setState] = React.useState({
color1: "",
color2: "",
isSelected: false,
});
ts

Then, in a useEffect hook, register a callback with the registerOnElementChange method:

React.useEffect(() => {
appElementClient.registerOnElementChange((element) => {
console.log(element);
});
}, []);
ts

This callback runs when:

  • An app element's data changes
  • A user selects an app element
  • A user deselects an app element

The callback receives an element parameter. When an element is selected, element contains a value. Otherwise, it's undefined. You can use this behavior to update the isSelected state:

React.useEffect(() => {
appElementClient.registerOnElementChange((element) => {
if (element) {
setState({
isSelected: true,
});
} else {
setState({
isSelected: false,
});
}
});
}, []);
ts

You can then update the UI based on the isSelected state:

<button type="submit" onClick={handleClick}>
{state.isSelected ? "Update element" : "Add element"}
</button>
tsx

There's a problem with these text fields:

<div>
<div>
<input
type="text"
name="color1"
value={state.color1}
placeholder="Color #1"
onChange={handleChange}
/>
</div>
<div>
<input
type="text"
name="color2"
value={state.color2}
placeholder="Color #2"
onChange={handleChange}
/>
</div>
<button type="submit" onClick={handleClick}>
Add or update element
</button>
</div>
tsx

These fields always show the values of the previously selected app element. This is because the state of the fields only update in response to input change events — not in response to the user's selection. As a result, the UI falls out of sync.

Here's what should happen:

  • If you select an app element, the fields should reflect the colors of the selected element.
  • If you deselect an app element, the fields should reset to an empty string.

To fix this, use the registerOnElementChange callback to update the state of the fields:

React.useEffect(() => {
appElementClient.registerOnElementChange((element) => {
if (element) {
setState({
color1: element.data.color1,
color2: element.data.color2,
isSelected: true,
});
} else {
setState({
color1: "",
color2: "",
isSelected: false,
});
}
});
}, []);
ts

The element parameter is an object that contains the app element's data. When an app element is selected, we can use this data to set the values of the text fields. If element is undefined, we can reset the text fields to empty strings. As a result, the UI remains in sync with the user's selection.

  • Users can't apply effects to app elements or the elements within them.
  • Users can't select the individual elements within an app element.
  • Users can't un-group the elements within an app element.
  • App elements can only be edited via the apps that created them.
  • App elements can't contain groups, videos, or other app elements.
  • Apps can only edit app elements while they're selected.
import React from "react";
import { initAppElement } from "@canva/design";
type AppElementData = {
color1: string;
color2: string;
};
const appElementClient = initAppElement<AppElementData>({
render: (data) => {
const dataUrl = createGradient(data.color1, data.color2);
return [
{
type: "IMAGE",
dataUrl,
width: 640,
height: 360,
top: 0,
left: 0,
},
];
},
});
export function App() {
const [state, setState] = React.useState({
color1: "",
color2: "",
isSelected: false,
});
React.useEffect(() => {
appElementClient.registerOnElementChange((element) => {
if (element) {
setState({
color1: element.data.color1,
color2: element.data.color2,
isSelected: true,
});
} else {
setState({
color1: "",
color2: "",
isSelected: false,
});
}
});
}, []);
function handleClick() {
appElementClient.addOrUpdateElement({
color1: state.color1,
color2: state.color2,
});
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setState((prevState) => {
return {
...prevState,
[event.target.name]: event.target.value,
};
});
}
return (
<div>
<div>
<input
type="text"
name="color1"
value={state.color1}
placeholder="Color #1"
onChange={handleChange}
/>
</div>
<div>
<input
type="text"
name="color2"
value={state.color2}
placeholder="Color #2"
onChange={handleChange}
/>
</div>
<button type="submit" onClick={handleClick}>
{state.isSelected ? "Update" : "Add"}
</button>
</div>
);
}
function createGradient(color1: string, color2: string): string {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Can't get CanvasRenderingContext2D");
}
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL();
}
tsx