Kogito Tooling Examples — How to create a custom Editor in a React application

Photograph by Cristina Mittermeier

We’re happy to announce our update on the Kogito Tooling Examples to help you understand how to use and integrate the Kogito Tooling packages on your project! The Kogito Tooling packages provide a simple way to start using the Multiplying Architecture. It’s major advantage is enabling you to embed an Editor or a View on your project.

Some useful links before we start:

This will be a series of blog posts, and this one covers “How to create a custom Editor in a React application”.

You can navigate through the series by clicking on the following topics:

Before we start, we need to remember the main concepts of the Multiplying Architecture:

  • Channel: The host application can be a Web App, a Chrome Extension, VS Code Extension, Desktop App, etc.
  • Envelope: An isolated application running inside an iframe, which can be an Editor or a View.

The Channel and the Envelope communicate by sending messages to each other. These messages can be notifications or requests. A notification is a message without a response (void), and a request implies that a response will be provided, thus returns a Promise. To know more about Channels and Envelopes, see this post.

How to create a custom Editor

You can create a custom Editor using any JavaScript technology you want for any file extension you want. In order for the custom Editor to be compatible with all features provided by Kogito Tooling, the file content must be a text (not binary). This is a major concern if you want to create a Chrome Extension for GitHub using the custom Editor, because at the time this blog post is being written, it’s not possible to edit a binary file on GitHub. (and I don’t think it will ever be 😁)

For this example, we will create an Editor using React for a brand new file extension called base64pn, which contains a PNG image converted to Base64. The idea is to create an Editor capable of tweaking the image properties but using a different file format.

To start it, we are going to need three important files:

  • Base64PngEditorFactory.ts
  • Base64PngEditorInterface.tsx
  • Base64PngEditor.tsx

Base64Png Editor Factory

This class is utilized on the initialization of the Envelope. It tells how to create a Base64Png Editor Interface (that contains the Base64Png Editor) and which file extensions the factory supports, in our case, just the base64png

import { EditorFactory, EditorInitArgs, KogitoEditorEnvelopeContextType } from "@kogito-tooling/editor/dist/api";
import { Base64PngEditorInterface } from "./Base64PngEditorInterface";
export class Base64PngEditorFactory implements EditorFactory {
public supports(fileExtension: string) {
return fileExtension === "base64png";
}
public createEditor(envelopeContext: KogitoEditorEnvelopeContextType, initArgs: EditorInitArgs) {
return Promise.resolve(new Base64PngEditorInterface(envelopeContext, initArgs));
}
}

On the initialization of the Base64 Editor Interface, one of the parameters is the envelopContext that gives access to all the services available on the Envelope through the services property (KeyboardShortcuts, GuidedTour, I18n). It provides a way to communicate with the Channel with the channelApi property. With the context property, it’s possible to access the O.S. and Channel (e.g., VS Code) the Envelope is running.

The initArgs isn’t utilized in this example. Still, with it, you have access to additional useful information, such as the resourcePathPrefix which is the initial path to where your resources are located (e.g., CSS, icons, fonts). The fileExtension in case your Editor supports multiple file extensions, this property is initialized with the current one. The initialLocale a useful property if your Editor supports internationalization (i18n), and an isReadOnly value if your Editor has a read-only mode.

Base64Png Editor Interface

This class implements an EditorInterface which determines some methods and properties required by the Channel to communicate with the Editor.

import { Editor, EditorApi, EditorInitArgs, KogitoEditorEnvelopeContextType } from "@kogito-tooling/editor/dist/api";
import { Rect } from "@kogito-tooling/guided-tour/dist/api";
import { Base64PngEditor } from "./Base64PngEditor";
export class Base64PngEditorInterface implements Editor {
private editorRef: React.RefObject<EditorApi>;
public af_isReact = true;
public af_componentId: "base64png-editor";
public af_componentTitle: "Base64 PNG Editor";
constructor(
private readonly envelopeContext: KogitoEditorEnvelopeContextType,
private readonly initArgs: EditorInitArgs
) {
this.editorRef = React.createRef<EditorApi>();
}
public getContent(): Promise<string> {
return this.editorRef.current?.getContent()!;
}
public getElementPosition(selector: string): Promise<Rect | undefined> {
return this.editorRef.current?.getElementPosition(selector)!;
}
public setContent(path: string, content: string): Promise<void> {
return this.editorRef.current?.setContent(path, content)!;
}
public getPreview(): Promise<string | undefined> {
return this.editorRef.current?.getPreview()!;
}
public undo(): Promise<void> {
return this.editorRef.current?.undo()!;
}
public redo(): Promise<void> {
return this.editorRef.current?.redo()!;
}
public af_componentRoot() {
return <Base64PngEditor ref={this.editorRef} envelopeContext={this.envelopeContext} />;
}
}

All methods, except the af_componentRoot are implemented by the Editor and are called using its reference. Some methods can be implemented directly on the interface in case your Editor doesn’t support it.

Base64Png Editor

The Editor itself. Here we will explore some functionalities and some choices that were made and explain the code. Here we’re going to use a lot of React concepts, like Hooks. In case you need a quick reference, here’s the documentation link.

Initial Editor

On this initial setup, we’ll end up with a minimal Base64Png Editor that can only receive a base64 file from the Channel (via setContent and send it back without any tweak ( getContent ) Here is how it’s going to look like:

The first look on our Base64Png Editor!

We manually set the content with this rainbow image. You can check the code for this section here.

Now we’re going to follow up with some explanations about what is used to create this Editor.

Why RefForwardingComponent

In this example, we used the RefForwardingComponent available on React to give imperative access to its parent component, which has its reference.

import { EditorApi, KogitoEditorEnvelopeContextType } from "@kogito-tooling/editor/dist/api";
interface Props {
envelopeContext: KogitoEditorEnvelopeContextType;
}
export const Base64PngEditor = React.forwardRef<EditorApi, Props>((props, forwardedRef) => {
// All the editor code is here.
}
view raw Base64PngEditor.ts hosted with ❤ by GitHub

We chose this approach to have access to the React Hooks. To expose the communication methods used by the Base64PngInterface, we utilize the useImperativeHandle hook.

import { DEFAULT_RECT } from "@kogito-tooling/guided-tour/dist/api";
export const Base64PngEditor = React.forwardRef<EditorApi, Props>((props, forwardedRef) => {
// …
useImperativeHandle(forwardedRef, () => {
return {
/**
* We are going to pass through on each of the method here.
*/
getContent: () => Promise.resolve(getContent()),
getPreview: () => Promise.resolve(getPreview()),
setContent: (path: string, content: string) => Promise.resolve(setContent(path, content)),
/**
* We're going to implement this methods further in the tutorial
*/
undo: () => Promise.resolve(),
redo: () => Promise.resolve(),
/**
* This method is not going to be implemented on this example, so it always resolve a DEFAULT_RECT.
*/
getElementPosition: (selector: string) => Promise.resolve(DEFAULT_RECT),
};
});
// …
})
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

As you can see in this Gist, all the following code will be inside the RefForwardingComponent , so it’ll be omited int the next Gists.

What is the receive_ready notification?

Before the Channel starts to send/receive requests to the Envelope, the Editor needs to inform that its ready to do so. To do it, we notify the Channel with a receive_ready notification after the first render, with a useEffect hook.

useEffect(() => {
props.envelopeContext.channelApi.notifications.receive_ready();
}, []);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Image and Canvas

States

To have access to the original image and the tweaked image, we need two states. We’re going to use the useState hook, which provides a state and a setter for the respective state.

  • Original Content — The original base64 value

All tweaks are made on top of the original value. This is used because tweaking the image would modify it to another image, and all subsequential tweaks would be applied on top of the last tweak.

  • Editor Content — The tweaked base64 value

The editorContent has the current value of all tweaks that it’s applied to the image. This value is the one used by the canvas to show the tweaked image.

const [originalContent, setOriginalContent] = useState("");
const [editorContent, setEditorContent] = useState("");
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

References

To have imperative access to the image and the canvas properties and methods, we must create references, and we use the useRef hook for this.

const imageRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

HTML/CSS

To start it simple, we’re going to render just the image and the canvas, passing the created references for each one.

export const Base64PngEditor = React.forwardRef<EditorApi, Props>((props, forwardedRef) => {
// …
return (
<div className={"base64png-editor–viewport"}>
<img
ref={imageRef}
className={"base64png-editor–image"}
src={`data:image/png;base64,${originalContent}`}
alt={"Original"}
/>
<canvas
ref={canvasRef}
className={"base64png-editor–canvas"}
/>
</div>
)
})
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Some important considerations:

  • The div has the entire screen size.
  • The image CSS is a display:none because we don’t want the image to be displayed. We just need the image content, so we can use it later to set the canvas content.
  • The canvas has an arbitrary 600×600 size.
.base64png-editor–div-viewport {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
.base64png-editor–image {
display: none;
}
.base64png-editor–canvas {
max-width: 600px;
max-height: 600px;
}

Canvas Content

To set the canvas content, we’re going to use a useEffect hook and the values on the imageRef and canvasRef so when the component is mounted, we can set the initial canvas size and then register a callback on the image, using the onload property. This callback will be called every time the content on the image is loaded, which is when we have access to the content and size, so we can update the canvas with it. The editorContent is set with the canvas content after removing the base64 header.

When the component is unmounted, we’re going to remove the registered callback on the onload property.

useEffect(() => {
const ctx = canvasRef.current?.getContext("2d")!;
/**
* Initial canvas size
*/
canvasRef.current!.width = 0;
canvasRef.current!.height = 0;
imageRef.current!.onload = () => {
/**
* Update the canvas size
*/
canvasRef.current!.width = imageRef.current!.width;
canvasRef.current!.height = imageRef.current!.height;
/**
* Draw the image on the canvas
*/
ctx.drawImage(imageRef.current!, 0, 0);
/**
* The toDataUrl() returns the base64 information in the format "<base64 header>,<base64 data>"
* We just need the base64 data.
*/
setEditorContent(canvasRef.current!.toDataURL().split(",")[1]);
};
return () => {
imageRef.current!.onload = null;
};
}, []);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

setContent/getContent/getPreview

These three methods are made with a useCallback hook, which will store the functionality without re-evaluating on each render. The setContent will set the originalContent state, and getContent retrieve the editorContent state. The getPreview will retrieve an SVG of the current content to be further used as a file image preview.

const getContent = useCallback(() => {
return editorContent;
}, [editorContent]);
const setContent = useCallback((path: string, content: string) => {
setOriginalContent(content);
}, []);
const getPreview = useCallback(() => {
const width = imageRef.current!.width;
const height = imageRef.current!.height;
return `
<svg version="1.1" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<image width="${width}" height="${height}" xlink:href="data:image/png;base64,${editorContent}" />
</svg>`;
}, [editorContent]);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Adding Tweakers

For this section, we’re going to see how to add an invert image tweaker to an image. It’ll use the filter property available on the canvas 2d context to apply the invert tweak (you can find more about the available filters here). We’re going to add a disabled state as well, so in case we have an invalid image or no image at all. Here is how the code will look like, and here’s a preview.

The Base64Png Editor with the Invert tweaker

Invert Tweaker

Before we start with the tweaker, we’re going to add a disabled state, whose primary purpose is to disable the possibility to apply tweaks on an invalid image. By default, the Editor starts disabled.

To handle the invert value, we need a new state as well. This state will handle the actual invert value applied to the image. We start it with 0, which is the default value.

const [invert, setInvert] = useState("0");
const tweakInvert = useCallback((value: string) => {
setInvert(value);
props.envelopeContext.channelApi.notifications.receive_newEdit({ id: new Date().getTime().toString() });
}, [invert]);
const [disabled, setDisabled] = useState(true);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

We need an useEffect hook to apply this invert value to the Canvas every time the invert state is updated. Then, we update the editorContent state with the current base64 data.

useEffect(() => {
const ctx = canvasRef.current?.getContext("2d")!;
ctx.filter = `invert(${invert}%)`;
ctx.drawImage(imageRef.current!, 0, 0);
setEditorContent(canvasRef.current!.toDataURL().split(",")[1]);
}, [invert]);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

To use the disabled state, we update the image onload callback to set the disabled to false.

useEffect(() => {
const ctx = canvasRef.current?.getContext("2d")!;
canvasRef.current!.width = 0;
canvasRef.current!.height = 0;
imageRef.current!.onload = () => {
canvasRef.current!.width = imageRef.current!.width;
canvasRef.current!.height = imageRef.current!.height;
ctx.drawImage(imageRef.current!, 0, 0);
setEditorContent(canvasRef.current!.toDataURL().split(",")[1]);
/**
* When the image finishes to load, the tweakers can properly work
*/
setDisabled(false);
};
return () => {
imageRef.current!.onload = null;
};
}, []);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Adding PatternFly

With all setup, we’re going to use those new states to show the user when the Editor is disabled, and enable them to tweak the invert value through a switch. To accomplish that, we’re going to use the PatternFly React library, which provides some useful components. To use it on your project, we recommend using the PatternFly React seed example (here).

export const Base64PngEditor = React.forwardRef<EditorApi, Props>((props, forwardedRef) => {
// …
return (
<div className={"base64png-editor–main"}>
<div className={"base64png-editor–viewport"}>
<img
ref={imageRef}
className={"base64png-editor–image"}
src={`data:image/png;base64,${originalContent}`}
alt={"Original"}
/>
{disabled && (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title headingLevel="h5" size="4xl">
Empty image
</Title>
</EmptyState>
)}
<canvas ref={canvasRef} className={"base64png-editor–canvas"} />
</div>
<div className={"base64png-editor–tweaks"}>
<Nav aria-label="Image tweaker">
<NavList>
<NavItem itemId={0}>
<div className={"base64png-editor–tweaks-nav-item"}>
<p>Invert</p>
<Switch
id="invert-switch"
isDisabled={disabled}
isChecked={invert === "100"}
onChange={(value) => tweakInvert(value ? "100" : "0")}
/>
</div>
</NavItem>
</NavList>
</Nav>
</div>
</div>
);
})
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

For this, we wrapped all the content around a div, which will separate the canvas and the tweaks controller. We’ve added a new component (EmptyState) to be shown when no image is provided on the canvas. On the tweaks side, we’ve utilized the navigation component (Nav) and a switch component (Switch) which will convert the boolean value to 0 (not inverted image) or to 100 (totally inverted image).

Adding State Control and Keyboard Shortcuts

This is our final setup covered on this blog post. We’re going to add a State Control service, so the user will be able to use the undo or redo operations through the Keyboard Shortcuts API. Here is the code for this section.

An example utilizing the Keyboard Shortcuts and the State Control!

This is the Base64Png Editor running inside our WebApp example. The image gallery is a custom React component, which interacts if the Editor.

State Control

To keep it simple, we will use the Kogito Tooling State Control implementation available with some modifications. Still, it’s up to you to implement your own State Control service or not. Here we’ve created an interface that represents an Edit and adds two new useful methods to parse the current Edit stored on the stack, representing a Base64PngEdit and another to clear the state control stack.

import { StateControl } from "@kogito-tooling/editor/dist/embedded";
import { KogitoEdit } from "@kogito-tooling/channel-common-api";
export interface Base64PngEdit extends KogitoEdit {
id: string;
filter: string;
invert: string;
}
export class Base64PngStateControl extends StateControl {
/**
* The command stack on the Kogito State Control accepts only strings, this method parses the string into a Base64PngEdit object.
*/
getCurrentBase64PngEdit(): Base64PngEdit | undefined {
const command = super.getCurrentCommand();
if (command) {
return JSON.parse(command) as Base64PngEdit;
}
return;
}
clearStateControl() {
super.setCurrentCommand(undefined);
super.setCommandStack([]);
}
}

To store the State Control instance, we’re going to use an useMemo hook, which will depend on the originalContent so the instance will be cleared every time a new file is set. Both undo and redo methods will rely on the Kogito Tooling State Control implementation, and it will be used to update the Editor state with the current Edit. In case the current Edit is undefined (bottom of the stack), the Editor should go back to the initial state.

const stateControl = useMemo(() => new Base64PngStateControl(), [originalContent]);
const undo = useCallback(() => {
stateControl.undo();
updateEditorStateWithCurrentEdit(stateControl.getCurrentBase64PngEdit());
}, [stateControl]);
const redo = useCallback(() => {
stateControl.redo();
updateEditorStateWithCurrentEdit(stateControl.getCurrentBase64PngEdit());
}, [stateControl]);
const updateEditorStateWithCurrentEdit = useCallback((edit?: Base64PngEdit) => {
if (edit) {
setInvert(edit.invert);
} else {
updateEditorToInitialState();
}
}, []);
const updateEditorToInitialState = useCallback(() => {
setInvert(INITIAL_INVERT);
}, []);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

With the State Control in place, we need to modify some methods to fully access its functionalities.

Firstly we’re going to modify the setContent method, and now, every time this method is called, it’ll clear the stateControl instance. This callback should be updated when the stateControl instance is modified, so we added it to the dependencies list of the hook. This is required because the setContent method can be called with the current originalContent and it will not trigger a new instance of State Control on the useMemo hook.

/**
* Clear the state control stack, and rollback the Editor tweak states to the initial values.
*/
const setContent = useCallback((path: string, content: string) => {
setOriginalContent(content);
stateControl.clearStateControl();
updateEditorToInitialState();
}, [stateControl]);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

It’s necessary to update the tweakInvert method to generate a Base64PngEdit command. This command will be stored on the stateControl stack and be notified to the Channel.

/**
* Create a Base64PngEdit and update the state control stack with it. We've updated the receive_newEdit notification with the Base64Edit in case the needs any additional information.
*/
const tweakInvert = useCallback((value: string) => {
setInvert(value);
const command: Base64PngEdit = {
id: new Date().getTime().toString(),
filter: `invert(${value})`,
invert: value,
};
stateControl.updateCommandStack(JSON.stringify(command));
props.envelopeContext.channelApi.notifications.receive_newEdit(command);
}, [invert, stateControl]);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

To apply the current Edit to the canvas, we update the useEffect hook responsible for applying the filter property on the canvas context.

/**
* Preferable use the state control current edit values.
*/
useEffect(() => {
const ctx = canvasRef.current?.getContext("2d")!;
ctx.filter = stateControl.getCurrentBase64PngEdit()?.filter ?? `invert(${invert}%)`;
ctx.drawImage(imageRef.current!, 0, 0);
setEditorContent(canvasRef.current!.toDataURL().split(",")[1]);
}, [invert, stateControl]);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Finally, using the State Control isDirty method, we added an indicator on the end of the NavList component.

/**
* Added on the end of the NavList an isDirty indicator.
*/
<NavItem itemId={0}>
<div className={"base64png-editor–tweaks-nav-item"}>
<p>Invert</p>
<Switch
id="invert-switch"
isDisabled={disabled}
isChecked={invert === "100"}
onChange={(value) => tweakInvert(value ? "100" : "0")}
/>
</div>
</NavItem>
{stateControl.isDirty() && (
<div style={{ display: "flex", alignItems: "center", padding: "20px" }}>
<p style={{ color: "red" }}>Image was Edited!</p>
</div>
)}
</NavList>
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Keyboard Shortcuts

It’s easy to register your shortcuts utilizing the available service on the envelopeContext. We use a useEffect hook to register it on the first render, and every time the invert or the disabled value is changed to keep the shortcut updated with the latest values. The registerKeyPress method will return its reference, used to deregister when the component is unmounted.

useEffect(() => {
const invertId = props.envelopeContext.services.keyboardShortcuts.registerKeyPress(
"i",
`Edit | Invert Image`,
async () => {
if (!disabled && invert === "100") {
tweakInvert("0");
} else if (!disabled && invert === "0") {
tweakInvert("100");
}
}
);
return () => {
props.envelopeContext.services.keyboardShortcuts.deregister(invertId);
};
}, [disabled, invert]);
view raw Base64PngEditor.tsx hosted with ❤ by GitHub

Wrapping up

With this last setup, we wrap up how to create a custom Editor. Our example repository has this same Editor with a few more tweakers (e.g., contrast, saturation, etc.). If you want to look at the finished Editor, you can check it here.

Check our next session to see how to Embed this Editor on a Chrome Extesnion or on the VS Code!

Thanks for read, and see you in the next section.

This post was original published on here.
0 0 vote
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments