JavaScript is required

Graph Editor with Context Menu and Local Save

This example turns relation-graph into a lightweight in-browser graph editor with toolbar-driven node and line creation, target-aware right-click deletion, relayout controls, and local save-and-restore. It also layers shared canvas settings and image export utilities over the same workspace, making it a compact reference for editable graph shells rather than a read-only viewer.

Building a Lightweight Graph Editor with Context Menu and Local Save

What This Example Builds

This example builds a full-screen graph editing workspace rather than a fixed viewer. Users can add nodes, create lines, delete selected objects, reapply layout, fit the viewport, and save the current graph state into browser local storage. A floating helper window stays available for instructions, canvas interaction settings, and image export.

The most useful part of the demo is how it combines several authoring behaviors into one small shell. The toolbar, right-click menu, and saved-state restore flow work together so the same canvas can act as both a starter tree view and a lightweight in-browser editor.

How the Data Is Organized

The initial graph is declared inline as staticJsonData with rootId, nodes, and lines. The starter payload is intentionally generic: a root node, several surrounding nodes, and a few labeled relationships. That keeps the example focused on editing behavior instead of a domain-specific schema.

Before setJsonData(...) runs, the code normalizes line IDs by mapping over the default line list and injecting line_auto_{index} when a line has no explicit id. That preprocessing matters because later delete actions remove lines by ID. The initialization flow also checks local storage under rg-my-graph-app; when saved graph JSON exists, that snapshot replaces the default payload.

The restore branch changes layout behavior before loading data. The first load uses a tree layout so the starter content is readable immediately, but restored snapshots switch to fixed layout so saved node coordinates survive a reload. In a real application, the same data shape could represent organization structures, dependency graphs, topology editors, lightweight modeling tools, or knowledge-map maintenance screens.

How relation-graph Is Used

RGProvider wraps the example so relation-graph hooks can resolve the active instance. RelationGraph receives a tree-layout option set with RGJunctionPoint.border, line text rendered on the path, and explicit vertical and horizontal gaps. Those defaults produce a readable starter arrangement while leaving room for later edits.

RGHooks.useGraphInstance() is the central integration point. The example uses it to load JSON with setJsonData(...), switch layouts with updateOptions(...), recenter and fit the viewport, translate pointer coordinates between view space and canvas space, save the live graph through getGraphJsonData(), remove nodes and lines by ID, and create new nodes and lines at runtime. The toolbar adds RGHooks.useViewInformation(), RGHooks.useCreatingNode(), and RGHooks.useCreatingLine() so the overlay can show live zoom percentage and reflect the current authoring mode.

Custom graph chrome is mounted through RGSlotOnView, which keeps the toolbar and transient context menu inside the graph view layer. That matters because the context menu is positioned from getViewXyByEvent(...), while canvas insertion converts the stored menu position back through getCanvasXyByViewXy(...). The shared DraggableWindow component adds a second overlay layer for instructions and settings; inside it, CanvasSettingsPanel reads RGHooks.useGraphStore() to update wheelEventAction and dragEventAction, and it uses the image-generation APIs to export the prepared canvas. The local SCSS file keeps node labels compact and highlights checked lines in orange.

Key Interactions

  • Clicking Save serializes the current graph JSON from the live instance and stores it in browser local storage.
  • Reloading after a save restores the saved graph under fixed layout so previous coordinates are reused instead of being recalculated.
  • Clicking Add Node starts interactive node placement, then inserts a generated node with randomized dimensions at the dropped canvas position.
  • Clicking Add Line starts interactive line creation; the toolbar also shows guidance while the user is choosing source and target nodes.
  • Right-clicking the canvas opens a floating menu that can add a node at the clicked location or start the same line-creation flow exposed by the toolbar.
  • Right-clicking a node or line opens a target-aware delete action that removes only the selected object.
  • Clicking Fit Content reframes the current graph, and clicking Layout switches back to tree layout and reruns doLayout().
  • Opening the helper window settings lets the user change wheel and drag behavior and download an image of the graph canvas.

Key Code Fragments

This block shows the default graph options that define the starter layout and line behavior.

const graphOptions: RGOptions = {
    debug: false,
    defaultJunctionPoint: RGJunctionPoint.border,
    defaultExpandHolderPosition: 'right',
    defaultLineTextOnPath: true,
    layout: {
        layoutName: 'tree',
        treeNodeGapV: 20,
        treeNodeGapH: 150
    }
};

This block shows the two-path initialization flow: normalize line IDs, prefer saved local data when present, and switch to fixed layout before restoring saved coordinates.

const myJsonData: RGJsonData = {
    ...staticJsonData,
    lines: staticJsonData.lines.map((line, index) => ({
        ...line,
        id: line.id || `line_auto_${index}`
    }))
};
const mySavedDataString = localStorage.getItem(myLocalDataItemKey);
const mySavedData = mySavedDataString ? (JSON.parse(mySavedDataString) as RGJsonData) : null;
if (mySavedData) {
    graphInstance.updateOptions({ layout: { layoutName: 'fixed' } });
    await graphInstance.setJsonData(mySavedData);
} else {
    await graphInstance.setJsonData(myJsonData);
}

This block shows the context-menu handler capturing both the clicked target type and the view-space coordinates used to place the overlay.

const onContextmenu = (e: RGUserEvent, objectType: string, object: any) => {
    const xyOnGraphView = graphInstance.getViewXyByEvent(e);
    setMenuPos(xyOnGraphView);
    setCurrentObj({ type: objectType, data: object });
    setShowNodeTipsPanel(true);

    const hideMenu = () => {
        setShowNodeTipsPanel(false);
        window.removeEventListener('click', hideMenu);
    };
    window.addEventListener('click', hideMenu);
};

This block shows runtime node authoring through startCreatingNodePlot(...), generated node IDs, and canvas-positioned insertion.

const startAddNode = (e: React.MouseEvent | React.TouchEvent) => {
    const randomWidth = 140 + (Math.floor(Math.random() * 40) - 40);
    const randomHeight = 30 + (Math.floor(Math.random() * 10) - 5);
    const newNodeSize = { width: randomWidth, height: randomHeight };
    graphInstance.startCreatingNodePlot(e.nativeEvent, {
        templateNode: { text: 'New Node', color: '#ffffff', ...newNodeSize },
        onCreateNode: (x, y) => {
            addNodeOnCanvas({
                x: x - newNodeSize.width / 2,
                y: y - newNodeSize.height / 2
            }, newNodeSize);
        }
    });
};

This block shows runtime line authoring after the user completes the interactive connection flow.

const startAddLine = (e: React.MouseEvent | React.TouchEvent) => {
    const newLineTemplate: JsonLineLike = { lineWidth: 2, color: '#cebf88ff', fontColor: '#cebf88ff', text: 'New Line' };
    graphInstance.startCreatingLinePlot(e.nativeEvent, {
        template: newLineTemplate,
        onCreateLine: (from, to) => {
            if (to && 'id' in to) {
                const lineId = graphInstance.generateNewUUID(5);
                graphInstance.addLines([{ id: lineId, ...newLineTemplate, from: from.id, to: to.id, text: `New Line-${lineId}` }]);
            }
        }
    });
};

This block shows how the RGSlotOnView overlay branches between canvas creation actions and object-specific deletion.

<RGSlotOnView>
    <MyGraphToolbar
        onSaveButtonClick={onSave}
        onAddNodeButtonClick={startAddNode}
        onAddLineButtonClick={startAddLine}
    />

    {showNodeTipsPanel && (
        <div style={{ left: `${menuPos.x}px`, top: `${menuPos.y}px` }}>
            {currentObj.type === 'canvas' && (
                <button onClick={() => {
                    const xyOnCanvas = graphInstance.getCanvasXyByViewXy(menuPos);
                    addNodeOnCanvas(xyOnCanvas);
                }}>
                    Add New Node
                </button>
            )}
            {(currentObj.type === 'node' || currentObj.type === 'line') && (
                <button onClick={handleDelete}>Delete {currentObj.type === 'node' ? 'Node' : 'Line'}</button>
            )}
        </div>
    )}
</RGSlotOnView>

This block shows the toolbar reflecting live graph state and exposing relayout as an explicit graph-instance action.

const graphInstance = RGHooks.useGraphInstance();
const viewInfomation = RGHooks.useViewInformation();
const creatingLine = RGHooks.useCreatingLine();
const creatingNode = RGHooks.useCreatingNode();

const layoutGraph = () => {
    graphInstance.updateOptions({
        layout: {
            layoutName: 'tree'
        }
    });
    graphInstance.doLayout();
};

This block shows the shared settings panel changing canvas behavior and exporting an image from the prepared graph DOM.

const graphInstance = RGHooks.useGraphInstance();
const { options } = RGHooks.useGraphStore();
const dragMode = options.dragEventAction;
const wheelMode = options.wheelEventAction;

const downloadImage = async () => {
    const canvasDom = await graphInstance.prepareForImageGeneration();
    let graphBackgroundColor = graphInstance.getOptions().backgroundColor;
    if (!graphBackgroundColor || graphBackgroundColor === 'transparent') {
        graphBackgroundColor = '#ffffff';
    }
    const imageBlob = await domToImageByModernScreenshot(canvasDom, {
        backgroundColor: graphBackgroundColor
    });
    if (imageBlob) {
        downloadBlob(imageBlob, 'my-image-name');
    }
    await graphInstance.restoreAfterImageGeneration();
};

What Makes This Example Distinct

The comparison data positions this example as a broader editor scaffold than create-object-from-menu. Both demos use a target-aware right-click menu inside RGSlotOnView, but this one expands that pattern with a persistent toolbar, a live zoom readout, fit and relayout controls, and local save-and-restore. That makes it a stronger starting point when a team needs an editable workspace instead of only a context-menu mutation pattern.

Another distinctive trait is the persistence flow. The example begins with relation-graph’s tree layout for a readable default arrangement, then switches to fixed layout only when restored graph JSON exists. That two-layout sequence preserves edited coordinates without giving up a clean initial presentation for first-time loads.

Compared with amount-summarizer and line-vertex-on-node, this example does not focus on typed node semantics or endpoint-placement controls. Its emphasis is a domain-agnostic authoring shell that can add nodes, connect nodes, delete selected objects, and reopen saved edits with very little surrounding business logic. Compared with map-world or use-dagre-layout, the floating overlays here serve live authoring and local persistence rather than replaying a prepared scene or writing back external layout coordinates.

Where Else This Pattern Applies

This pattern fits internal tools that need direct graph maintenance without a large workflow system. Examples include dependency editing, organization maintenance, infrastructure topology cleanup, lightweight modeling boards, and knowledge-map curation tools where users need to add a missing node, connect two existing objects, remove an invalid relation, and save the current state for later.

It also works as a migration step from a read-only relation graph to a more interactive editor. Teams can start with this toolbar-plus-context-menu shell, then extend it with validation, richer forms, remote persistence, permissions, or domain-specific node templates once the basic add or connect or delete or restore workflow is proven.