DEV Community

Ytalo
Ytalo

Posted on

Iterating Over a Visual Editor Compiler

The Visual Editor

When it comes to creating visual editors for workflows, React Flow stands out as an ideal choice. It offers robust performance, is highly customizable, and facilitates document export. With it, you can build everything from chatbots to complex backends.

Compiling the Visual

Directly exporting the visual format is not efficient for execution. Therefore, we convert this structure into something more executable. A basic example of the structure would be:

[{prev: null, data: {}, id: id, next: <some id>}]
Enter fullscreen mode Exit fullscreen mode

This makes it easy to navigate and filter the objects.

Data Maintenance

To reverse the "compilation" and maintain node metadata, we use:

const step: any = {
    data: node.data,
    height: node.height,
    id: node.id,
    position: node.position,
    positionAbsolute: node.positionAbsolute,
    selected: node.selected,
    type: node.type,
    width: node.width,
    prev: another step,
    next: another step
};
Enter fullscreen mode Exit fullscreen mode

[Format Conversion

Below is the function that converts the React Flow format to a custom format:

function convertToCustomFormat(reactFlowObject: any) {
    const customFormatObject: any = {
        steps: [],
        viewport: reactFlowObject.flow.viewport,
    };

    reactFlowObject.flow.nodes.forEach((node: any) => {
        const step: any = {
            data: node.data,
            height: node.height,
            id: node.id,
            position: node.position,
            positionAbsolute: node.positionAbsolute,
            selected: node.selected,
            type: node.type,
            width: node.width,
        };

        if (node.data.nodeType === "conditionNode" || node.data.nodeType === "pixNode") {
            const trueEdge = reactFlowObject.flow.edges.find(
                (edge: any) => edge.source === node.id && edge.sourceHandle == "a"
            );
            const falseEdge = reactFlowObject.flow.edges.find(
                (edge: any) => edge.source === node.id && edge.sourceHandle == "b"
            );

            step.true = trueEdge ? trueEdge : null;
            step.false = falseEdge ? falseEdge : null;
            step.prev = reactFlowObject.flow.edges.find(
                (edge: any) => edge.target === node.id
            );
        } else {
            step.prev = reactFlowObject.flow.edges.find(
                (edge: any) => edge.target === node.id
            );
            step.next = reactFlowObject.flow.edges.find(
                (edge: any) => edge.source === node.id
            );
        }

        customFormatObject.steps.push(step);
    });

    return customFormatObject;
}
Enter fullscreen mode Exit fullscreen mode

Reconversion to React Flow

To reconvert the custom format back to React Flow, we use the following function:

function convertToReactFlowFormat(customFormatObject: any) {
    const reactFlowObject: any = {
        flow: {
            nodes: [],
            edges: [],
            viewport: customFormatObject.viewport,
        },
    };

    customFormatObject.steps.forEach((step: any) => {
        const node = {
            data: step.data,
            height: step.height,
            id: step.id,
            position: step.position,
            positionAbsolute: step.positionAbsolute,
            selected: step.selected,
            type: step.type,
            width: step.width,
        };

        reactFlowObject.flow.nodes.push(node);

        if (step.type === "nodeContainer") {
            if (step.prev) {
                reactFlowObject.flow.edges.push({
                    ...step.prev,
                    target: step.id,
                });
            }

            if (step.next) {
                reactFlowObject.flow.edges.push({
                    ...step.next,
                    source: step.id,
                });
            }
        }
    });

    return reactFlowObject;
}
Enter fullscreen mode Exit fullscreen mode

Execution

To execute from the metadata, especially when user inputs are needed, we can use variables that change continuously and can be stored in memory or in a database like Redis. We keep the step ID, the number of steps passed, and metadata for each previous step or input.

This allows us to pause and resume execution easily, maintaining the flow logic intact. In cases where there are multiple outputs, we adapt the function to handle specific actions:

const step: any = {
    data: node.data,

height: node.height,
    id: node.id,
    position: node.position,
    positionAbsolute: node.positionAbsolute,
    selected: node.selected,
    type: node.type,
    width: node.width,
    prev: step,
    outputs: {
        0: step,
        1: step
    }
};
Enter fullscreen mode Exit fullscreen mode

This allows for even more detailed and specific management of the constructed flow.

Introducing the Visitor Pattern

For those looking for a more structured and scalable way to iterate over the elements of a visual editor, the Visitor Pattern can be an intriguing solution. The Visitor Pattern allows you to separate algorithms from the objects on which they operate, making it easier to add new operations without modifying the existing elements.

What is the Visitor Pattern?

The Visitor Pattern involves creating a visitor interface that declares a set of visit methods for each type of element. Each element class implements an accept method that accepts a visitor, allowing the visitor to perform the required operation.

Benefits

  • Simplified Maintenance: Adding new operations only requires creating a new visitor without needing to modify existing elements.
  • Separation of Responsibilities: Operations are clearly separated from the elements, making the code more modular.
  • Scalability: Facilitates the addition of new types of elements and operations in an orderly and hassle-free manner.

Basic Example

Here is a basic example of how the Visitor Pattern can be implemented:

class Visitor {
  visitText(element) {}
  visitImage(element) {}
  visitShape(element) {}
}

class ConcreteVisitor extends Visitor {
  visitText(element) {
    console.log('Processing text element');
  }
  visitImage(element) {
    console.log('Processing image element');
  }
  visitShape(element) {
    console.log('Processing shape element');
  }
}

class Element {
  accept(visitor) {}
}

class TextElement extends Element {
  accept(visitor) {
    visitor.visitText(this);
  }
}

class ImageElement extends Element {
  accept(visitor) {
    visitor.visitImage(this);
  }
}

class ShapeElement extends Element {
  accept(visitor) {
    visitor.visitShape(this);
  }
}

function processElements(elements, visitor) {
  for (let element of elements) {
    element.accept(visitor);
  }
}

const elements = [new TextElement(), new ImageElement(), new ShapeElement()];
const visitor = new ConcreteVisitor();
processElements(elements, visitor);
Enter fullscreen mode Exit fullscreen mode

Considering the use of the Visitor Pattern can help keep your code more organized and adaptable to future changes. If you are iterating over a complex set of elements and need to apply multiple operations, this pattern can be an excellent choice.

This article was built based on the BatAnBot project and the recent technical challenge by Vom.

Top comments (0)