feat: allow to load an XML-stored model produced by `mxGraph` (#300)

Implementation relies on both the declaration of aliases and the
introduction of dedicated codecs.
`Codec.getCodecByName` now takes aliases into account, so that importing
is fully functional.
The code of some `CodecRegistry` and `Codec` methods has been simplified
(check for nullity of method parameters).

The cell style is converted thanks to a specific utility function that
can serve as an example for those wishing to migrate their applications
from mxGraph to maxGraph.

test
- The `Codec` example has been restored from the stashed directory. It
is now available as a story written in TypeScript.
- The `ModelChecker` utility class was previously used only to validate
the imported model after import from the maxGraph model. It is now
shared and also used in mxGraph import tests. It has also been enhanced
to check the total number of cells in the model.
development
Thomas Bouffard 2024-01-16 11:03:56 +01:00 committed by GitHub
parent 41a3fbfd55
commit 835bfe7ce9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 713 additions and 397 deletions

View File

@ -0,0 +1,81 @@
/*
Copyright 2024-present The maxGraph project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, expect, test } from '@jest/globals';
import { convertStyleFromString } from '../../../../src/serialization/codecs/mxGraph/utils';
import { CellStyle } from '../../../../src';
describe('convertStyleFromString', () => {
test('Basic', () => {
// adapted from https://github.com/maxGraph/maxGraph/issues/221
expect(
convertStyleFromString(
'rounded=0;whiteSpace=wrap;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=27'
)
).toEqual({
rounded: 0, // FIX should be true
whiteSpace: 'wrap',
fillColor: '#dae8fc',
strokeColor: '#6c8ebf',
fontStyle: 1,
fontSize: 27,
});
});
test('With leading ;', () => {
// To update when implementing https://github.com/maxGraph/maxGraph/issues/154
expect(convertStyleFromString(';arcSize=4;endSize=5;')).toEqual({
arcSize: 4,
endSize: 5,
});
});
test('With trailing ;', () => {
// from https://github.com/maxGraph/maxGraph/issues/102#issuecomment-1225577772
expect(
convertStyleFromString(
'rounded=0;whiteSpace=wrap;html=1;fillColor=#E6E6E6;dashed=1;'
)
).toEqual({
rounded: 0, // FIX should be true
whiteSpace: 'wrap',
html: 1, // custom draw.io
fillColor: '#E6E6E6',
dashed: 1,
});
});
// manage base name style (no = at the begining and in the middle)
test('With base name style', () => {
expect(
convertStyleFromString(
'rectangle;fontColor=yellow;customRectangle;gradientColor=white;'
)
).toEqual(<CellStyle>{
baseStyleNames: ['rectangle', 'customRectangle'],
fontColor: 'yellow',
gradientColor: 'white',
});
});
// renamed properties (see migration guide)
test('With renamed properties', () => {
// @ts-ignore
expect(convertStyleFromString('autosize=1')).toEqual(<CellStyle>{
autoSize: 1, // FIX should be true
});
});
});

View File

@ -0,0 +1,138 @@
/*
Copyright 2024-present The maxGraph project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, test } from '@jest/globals';
import { ModelChecker } from './utils';
import {
CodecRegistry,
Geometry,
GraphDataModel,
ModelXmlSerializer,
Point,
} from '../../src';
describe('import mxGraph model', () => {
test('Model with geometry', () => {
const mxGraphModelAsXml = `<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" vertex="1" parent="1" value="Vertex #2">
<mxGeometry x="380" y="20" width="140" height="30" as="geometry"/>
</mxCell>
<mxCell id="3" vertex="1" parent="1" value="Vertex #3">
<mxGeometry x="200" y="80" width="380" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" edge="1" source="2" target="3" parent="1" value="Edge #7">
<mxGeometry as="geometry">
<Array as="points">
<Object x="420" y="60"/>
</Array>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
`;
const model = new GraphDataModel();
new ModelXmlSerializer(model).import(mxGraphModelAsXml);
const modelChecker = new ModelChecker(model);
modelChecker.checkRootCells();
modelChecker.checkCellsCount(5);
modelChecker.expectIsVertex(model.getCell('2'), 'Vertex #2', {
geometry: new Geometry(380, 20, 140, 30),
});
modelChecker.expectIsVertex(model.getCell('3'), 'Vertex #3', {
geometry: new Geometry(200, 80, 380, 30),
});
const edgeGeometry = new Geometry();
edgeGeometry.points = [new Point(420, 60)];
modelChecker.expectIsEdge(model.getCell('7'), 'Edge #7', {
geometry: edgeGeometry,
});
});
test('Model with style', () => {
const xmlWithStyleAttribute = `<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" vertex="1" parent="1" value="Vertex with style" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E6E6E6;dashed=1;">
</mxCell>
</root>
</mxGraphModel>`;
const model = new GraphDataModel();
new ModelXmlSerializer(model).import(xmlWithStyleAttribute);
const modelChecker = new ModelChecker(model);
modelChecker.checkRootCells();
modelChecker.checkCellsCount(3);
modelChecker.expectIsVertex(model.getCell('2'), 'Vertex with style', {
style: {
// @ts-ignore FIX should be true
dashed: 1,
fillColor: '#E6E6E6',
html: 1,
// @ts-ignore FIX should be false
rounded: 0,
whiteSpace: 'wrap',
},
});
});
});
describe('import model from draw.io', () => {
test('from https://github.com/maxGraph/maxGraph/issues/221', () => {
const model = new GraphDataModel();
new ModelXmlSerializer(model)
.import(`<mxGraphModel dx="1502" dy="926" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1"
pageScale="1" pageWidth="1920" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2Ija7sB8CSz23ppRtK8d-5" value="PostgreSQL"
style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=27;"
parent="1" vertex="1">
<mxGeometry x="650" y="430" width="210" height="80" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>`);
const modelChecker = new ModelChecker(model);
modelChecker.checkRootCells();
modelChecker.checkCellsCount(3);
modelChecker.expectIsVertex(model.getCell('2Ija7sB8CSz23ppRtK8d-5'), 'PostgreSQL', {
geometry: new Geometry(650, 430, 210, 80),
style: {
fillColor: '#dae8fc',
fontSize: 27,
fontStyle: 1,
html: 1,
// @ts-ignore FIX should be false
rounded: 0,
strokeColor: '#6c8ebf',
whiteSpace: 'wrap',
},
});
});
});

View File

@ -15,15 +15,9 @@ limitations under the License.
*/ */
import { describe, expect, test } from '@jest/globals'; import { describe, expect, test } from '@jest/globals';
import { ModelChecker } from './utils';
import { createGraphWithoutContainer } from '../utils'; import { createGraphWithoutContainer } from '../utils';
import { import { Cell, Geometry, GraphDataModel, ModelXmlSerializer, Point } from '../../src';
Cell,
type CellStyle,
Geometry,
GraphDataModel,
ModelXmlSerializer,
Point,
} from '../../src';
// inspired by VertexMixin.createVertex // inspired by VertexMixin.createVertex
const newVertex = (id: string, value: string) => { const newVertex = (id: string, value: string) => {
@ -47,68 +41,6 @@ const getParent = (model: GraphDataModel) => {
return model.getRoot()!.getChildAt(0); return model.getRoot()!.getChildAt(0);
}; };
type ExpectCellProperties = {
geometry?: Geometry;
style?: CellStyle;
};
/**
* Utility class to check the model after import.
*/
class ModelChecker {
constructor(private model: GraphDataModel) {}
checkRootCells() {
const cell0 = this.model.getCell('0');
expect(cell0).toBeDefined();
expect(cell0).not.toBeNull();
expect(cell0?.parent).toBeNull();
const cell1 = this.model.getCell('1');
expect(cell1).toBeDefined();
expect(cell1).not.toBeNull();
expect(cell1?.parent).toBe(cell0);
}
expectIsVertex(cell: Cell | null, value: string, properties?: ExpectCellProperties) {
this.checkCellBaseProperties(cell, value, properties);
if (!cell) return; // cannot occur, this is enforced by checkCellBaseProperties
expect(cell.edge).toEqual(false);
expect(cell.isEdge()).toBeFalsy();
expect(cell.vertex).toEqual(1); // FIX should be set to true
expect(cell.isVertex()).toBeTruthy();
}
expectIsEdge(
cell: Cell | null,
value: string | null = null,
properties?: ExpectCellProperties
) {
this.checkCellBaseProperties(cell, value, properties);
if (!cell) return; // cannot occur, this is enforced by checkCellBaseProperties
expect(cell.edge).toEqual(1); // FIX should be set to true
expect(cell.isEdge()).toBeTruthy();
expect(cell.vertex).toEqual(false);
expect(cell.isVertex()).toBeFalsy();
}
private checkCellBaseProperties(
cell: Cell | null,
value: string | null,
properties?: ExpectCellProperties
) {
expect(cell).toBeDefined();
expect(cell).not.toBeNull();
if (!cell) return; // cannot occur, see above
expect(cell.value).toEqual(value);
expect(cell.getParent()?.id).toEqual('1'); // default parent id
expect(cell.geometry).toEqual(properties?.geometry ?? null);
expect(cell.style).toEqual(properties?.style ?? {});
}
}
// Adapted from https://github.com/maxGraph/maxGraph/issues/178 // Adapted from https://github.com/maxGraph/maxGraph/issues/178
const xmlWithSingleVertex = `<GraphDataModel> const xmlWithSingleVertex = `<GraphDataModel>
<root> <root>
@ -201,6 +133,7 @@ describe('import before the export (reproduce https://github.com/maxGraph/maxGra
const modelChecker = new ModelChecker(model); const modelChecker = new ModelChecker(model);
modelChecker.checkRootCells(); modelChecker.checkRootCells();
modelChecker.checkCellsCount(3);
modelChecker.expectIsVertex(model.getCell('B_#0'), 'rootNode', { modelChecker.expectIsVertex(model.getCell('B_#0'), 'rootNode', {
geometry: new Geometry(100, 100, 100, 80), geometry: new Geometry(100, 100, 100, 80),
@ -305,6 +238,7 @@ describe('import after export', () => {
const modelChecker = new ModelChecker(model); const modelChecker = new ModelChecker(model);
modelChecker.checkRootCells(); modelChecker.checkRootCells();
modelChecker.checkCellsCount(3);
modelChecker.expectIsVertex(model.getCell('B_#0'), 'rootNode', { modelChecker.expectIsVertex(model.getCell('B_#0'), 'rootNode', {
geometry: new Geometry(100, 100, 100, 80), geometry: new Geometry(100, 100, 100, 80),

View File

@ -0,0 +1,85 @@
/*
Copyright 2024-present The maxGraph project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { expect } from '@jest/globals';
import type { Cell, CellStyle, Geometry, GraphDataModel } from '../../src';
type ExpectCellProperties = {
geometry?: Geometry;
style?: CellStyle;
};
/**
* Utility class to check the content of GraphDataModel.
*/
export class ModelChecker {
constructor(private model: GraphDataModel) {}
checkRootCells() {
const cell0 = this.model.getCell('0');
expect(cell0).toBeDefined();
expect(cell0).not.toBeNull();
expect(cell0?.parent).toBeNull();
const cell1 = this.model.getCell('1');
expect(cell1).toBeDefined();
expect(cell1).not.toBeNull();
expect(cell1?.parent).toBe(cell0);
}
checkCellsCount(count: number) {
const cellIds = Object.getOwnPropertyNames(this.model.cells);
expect(cellIds).toHaveLength(count);
}
expectIsVertex(cell: Cell | null, value: string, properties?: ExpectCellProperties) {
this.checkCellBaseProperties(cell, value, properties);
if (!cell) return; // cannot occur, this is enforced by checkCellBaseProperties
expect(cell.edge).toEqual(false);
expect(cell.isEdge()).toBeFalsy();
expect(cell.vertex).toEqual(1); // FIX should be set to true
expect(cell.isVertex()).toBeTruthy();
}
expectIsEdge(
cell: Cell | null,
value: string | null = null,
properties?: ExpectCellProperties
) {
this.checkCellBaseProperties(cell, value, properties);
if (!cell) return; // cannot occur, this is enforced by checkCellBaseProperties
expect(cell.edge).toEqual(1); // FIX should be set to true
expect(cell.isEdge()).toBeTruthy();
expect(cell.vertex).toEqual(false);
expect(cell.isVertex()).toBeFalsy();
}
private checkCellBaseProperties(
cell: Cell | null,
value: string | null,
properties?: ExpectCellProperties
) {
expect(cell).toBeDefined();
expect(cell).not.toBeNull();
if (!cell) return; // cannot occur, see above
expect(cell.value).toEqual(value);
expect(cell.getParent()?.id).toEqual('1'); // default parent id
expect(cell.geometry).toEqual(properties?.geometry ?? null);
expect(cell.style).toEqual(properties?.style ?? {});
}
}

View File

@ -23,7 +23,6 @@ import Cell from '../view/cell/Cell';
import MaxLog from '../gui/MaxLog'; import MaxLog from '../gui/MaxLog';
import { getFunctionName } from '../util/StringUtils'; import { getFunctionName } from '../util/StringUtils';
import { importNode, isNode } from '../util/domUtils'; import { importNode, isNode } from '../util/domUtils';
import ObjectCodec from './ObjectCodec';
const createXmlDocument = () => { const createXmlDocument = () => {
return document.implementation.createDocument('', '', null); return document.implementation.createDocument('', '', null);
@ -415,35 +414,35 @@ class Codec {
* and insertEdge on the parent and terminals, respectively. * and insertEdge on the parent and terminals, respectively.
* Default is `true`. * Default is `true`.
*/ */
decodeCell(node: Element, restoreStructures = true): Cell { decodeCell(node: Element, restoreStructures = true): Cell | null {
let cell = null; if (node?.nodeType !== NODETYPE.ELEMENT) {
return null;
}
if (node != null && node.nodeType === NODETYPE.ELEMENT) { // Tries to find a codec for the given node name. If that does
// Tries to find a codec for the given node name. If that does // not return a codec then the node is the user object (an XML node
// not return a codec then the node is the user object (an XML node // that contains the mxCell, aka inversion).
// that contains the mxCell, aka inversion). let decoder = CodecRegistry.getCodec(node.nodeName);
let decoder = CodecRegistry.getCodec(node.nodeName);
// Tries to find the codec for the cell inside the user object. // Tries to find the codec for the cell inside the user object.
// This assumes all node names inside the user object are either // This assumes all node names inside the user object are either
// not registered or they correspond to a class for cells. // not registered or they correspond to a class for cells.
if (!this.isCellCodec(decoder)) { if (!this.isCellCodec(decoder)) {
let child = node.firstChild as Element; let child = node.firstChild as Element;
while (child != null && !this.isCellCodec(decoder)) { while (child != null && !this.isCellCodec(decoder)) {
decoder = CodecRegistry.getCodec(child.nodeName); decoder = CodecRegistry.getCodec(child.nodeName);
child = child.nextSibling as Element; child = child.nextSibling as Element;
}
} }
}
if (!this.isCellCodec(decoder)) { if (!this.isCellCodec(decoder)) {
decoder = CodecRegistry.getCodec(Cell); decoder = CodecRegistry.getCodec(Cell);
} }
cell = decoder?.decode(this, node); const cell = decoder?.decode(this, node);
if (restoreStructures) { if (restoreStructures) {
this.insertIntoGraph(cell); this.insertIntoGraph(cell);
}
} }
return cell; return cell;
} }

View File

@ -54,17 +54,18 @@ class CodecRegistry {
static aliases: { [key: string]: string | undefined } = {}; static aliases: { [key: string]: string | undefined } = {};
/** /**
* Registers a new codec and associates the name of the template constructor in the codec with the codec object. * Registers a new codec and associates the name of the codec via {@link ObjectCodec.getName} with the codec object.
* *
* @param codec ObjectCodec to be registered. * @param codec ObjectCodec to be registered.
* @param registerAlias if `true`, register an alias if the codec name doesn't match the name of the constructor of {@link ObjectCodec.template}.
*/ */
static register(codec: ObjectCodec): ObjectCodec { static register(codec: ObjectCodec, registerAlias = true): ObjectCodec {
if (codec != null) { if (codec != null) {
const name = codec.getName(); const name = codec.getName();
CodecRegistry.codecs[name] = codec; CodecRegistry.codecs[name] = codec;
const classname: string = codec.template.constructor.name; const classname: string = codec.template.constructor.name;
if (classname !== name) { if (registerAlias && classname !== name) {
CodecRegistry.addAlias(classname, name); CodecRegistry.addAlias(classname, name);
} }
} }
@ -79,38 +80,59 @@ class CodecRegistry {
} }
/** /**
* Returns a codec that handles objects that are constructed using the given constructor. * Returns a codec that handles objects that are constructed using the given constructor or a codec registered under the provided name.
* *
* @param constructor_ JavaScript constructor function. * When passing a name, the method first check if an alias exists for the name, and if so, it uses it to retrieve the codec.
*
* If there is no registered Codec, the method tries to register a new Codec using the provided constructor.
*
* @param constructorOrName JavaScript constructor function of the Codec or Codec name.
*/ */
static getCodec(constructor_: any): ObjectCodec | null { static getCodec(constructorOrName: any): ObjectCodec | null {
if (constructorOrName == null) {
return null;
}
let codec = null; let codec = null;
if (constructor_ != null) { // Equivalent of calling import { getFunctionName } from '../util/StringUtils';
let { name } = constructor_; let name =
const tmp = CodecRegistry.aliases[name]; typeof constructorOrName === 'string' ? constructorOrName : constructorOrName.name;
if (tmp != null) { const tmp = CodecRegistry.aliases[name];
name = tmp; if (tmp != null) {
} name = tmp;
}
codec = CodecRegistry.codecs[name] ?? null; codec = CodecRegistry.codecs[name] ?? null;
// Registers a new default codec for the given constructor if no codec has been previously defined. // Registers a new default codec for the given constructor if no codec has been previously defined.
if (codec == null) { if (codec == null) {
try { try {
codec = new ObjectCodec(new constructor_()); codec = new ObjectCodec(new constructorOrName());
CodecRegistry.register(codec); CodecRegistry.register(codec);
} catch (e) { } catch (e) {
// ignore // ignore
}
} }
} }
return codec; return codec;
} }
/**
* First try to get the codec by the name it is registered with. If it doesn't exist, use the alias eventually declared
* to get the codec.
* @param name the name of the codec that is willing to be retrieved.
*/
static getCodecByName(name: string) { static getCodecByName(name: string) {
return CodecRegistry.codecs[name] ?? null; let codec = CodecRegistry.codecs[name];
if (!codec) {
const alias = CodecRegistry.aliases[name];
if (alias) {
codec = CodecRegistry.codecs[alias];
}
}
return codec ?? null;
} }
} }

View File

@ -765,7 +765,6 @@ class ObjectCodec {
} }
if (!this.isExcluded(obj, name, value, false)) { if (!this.isExcluded(obj, name, value, false)) {
// MaxLog.debug(mxUtils.getFunctionName(obj.constructor)+'.'+name+'='+value);
obj[name] = value; obj[name] = value;
} }
} }
@ -858,7 +857,6 @@ class ObjectCodec {
} else { } else {
obj.push(value); obj.push(value);
} }
// MaxLog.debug('Decoded '+mxUtils.getFunctionName(obj.constructor)+'.'+fieldname+': '+value);
} }
} }

View File

@ -30,8 +30,8 @@ export class ModelCodec extends ObjectCodec {
} }
/** /**
* Encodes the given {@link GraphDataModel} by writing a (flat) XML sequence of cell nodes as produced by the <CellCodec>. * Encodes the given {@link GraphDataModel} by writing a (flat) XML sequence of cell nodes as produced by the {@link CellCodec}.
* The sequence is wrapped-up in a node with the name root. * The sequence is wrapped-up in a node with the name `root`.
*/ */
encodeObject(enc: any, obj: Cell, node: Element) { encodeObject(enc: any, obj: Cell, node: Element) {
const rootNode = enc.document.createElement('root'); const rootNode = enc.document.createElement('root');

View File

@ -23,3 +23,5 @@ export * from './ModelCodec';
export * from './RootChangeCodec'; export * from './RootChangeCodec';
export * from './StylesheetCodec'; export * from './StylesheetCodec';
export * from './TerminalChangeCodec'; export * from './TerminalChangeCodec';
export * from './mxGraph/mxCellCodec';
export * from './mxGraph/mxGeometryCodec';

View File

@ -0,0 +1,37 @@
/*
Copyright 2024-present The maxGraph project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { convertStyleFromString } from './utils';
import { CellCodec } from '../CellCodec';
import type Codec from '../../Codec';
/**
* CellCodec to support the legacy `mxGraph` format.
*/
export class mxCellCodec extends CellCodec {
getName(): string {
return 'mxCell';
}
decodeAttribute(dec: Codec, attr: any, obj?: any) {
const attributeNodeName = attr.nodeName;
if (obj && attributeNodeName == 'style') {
obj['style'] = convertStyleFromString(attr.value);
} else {
super.decodeAttribute(dec, attr, obj);
}
}
}

View File

@ -0,0 +1,54 @@
/*
Copyright 2024-present The maxGraph project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type Codec from '../../Codec';
import ObjectCodec from '../../ObjectCodec';
import Geometry from '../../../view/geometry/Geometry';
import Point from '../../../view/geometry/Point';
export class mxGeometryCodec extends ObjectCodec {
getName(): string {
return 'mxGeometry';
}
constructor() {
super(new Geometry());
}
afterDecode(dec: Codec, node: Element | null, obj?: any): any {
// Convert points to the right form
// input: [ { x: 420, y: 60 }, ... ]
// output: [ Point { _x: 420, _y: 60 }, ... ]
//
// In mxGraph XML, the points are modeled as Object, so there is no way to create an alias to do the decoding with a custom Codec.
// Then, it is easier to convert the values to Point objects after the whole decoding of the geometry
// <Array as="points">
// <Object x="420" y="60"/>
// </Array>
const originalPoints = (obj as Geometry).points;
if (originalPoints) {
const points: Array<Point> = [];
for (const pointInput of originalPoints) {
const rawPoint = pointInput as { x: number; y: number };
points.push(new Point(rawPoint.x, rawPoint.y));
}
(obj as Geometry).points = points;
}
return obj;
}
}

View File

@ -0,0 +1,56 @@
/*
Copyright 2024-present The maxGraph project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { isNumeric } from '../../../util/mathUtils';
import type { CellStyle } from '../../../types';
// from mxGraph to maxGraph
const fieldMapping = new Map<string, string>([['autosize', 'autoSize']]);
export function convertStyleFromString(input: string) {
const style: CellStyle = {};
const elements = input
.split(';')
// filter empty key
.filter(([k]) => k);
for (const element of elements) {
if (!element.includes('=')) {
!style.baseStyleNames && (style.baseStyleNames = []);
style.baseStyleNames.push(element);
} else {
const [key, value] = element.split('=');
// @ts-ignore
style[fieldMapping.get(key) ?? key] = convertToNumericIfNeeded(value);
}
}
return style;
}
function convertToNumericIfNeeded(value: string): string | number {
// Adapted from ObjectCodec.convertAttributeFromXml
if (!isNumeric(value)) {
return value;
}
let numericValue = parseFloat(value);
if (Number.isNaN(numericValue) || !Number.isFinite(numericValue)) {
numericValue = 0;
}
return numericValue;
}

View File

@ -25,6 +25,8 @@ import {
GenericChangeCodec, GenericChangeCodec,
GraphViewCodec, GraphViewCodec,
ModelCodec, ModelCodec,
mxCellCodec,
mxGeometryCodec,
RootChangeCodec, RootChangeCodec,
StylesheetCodec, StylesheetCodec,
TerminalChangeCodec, TerminalChangeCodec,
@ -88,6 +90,11 @@ export const registerCoreCodecs = (force = false) => {
CodecRegistry.register(new ObjectCodec({})); // Object CodecRegistry.register(new ObjectCodec({})); // Object
CodecRegistry.register(new ObjectCodec([])); // Array CodecRegistry.register(new ObjectCodec([])); // Array
// mxGraph support
CodecRegistry.addAlias('mxGraphModel', 'GraphDataModel');
CodecRegistry.register(new mxCellCodec(), false);
CodecRegistry.register(new mxGeometryCodec(), false);
isCoreCodecsRegistered = true; isCoreCodecsRegistered = true;
} }
}; };

View File

@ -0,0 +1,179 @@
/*
Copyright 2024-present The maxGraph project Contributors
Copyright (c) 2006-2013, JGraph Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
Graph,
InternalEvent,
ModelXmlSerializer,
type PanningHandler,
} from '@maxgraph/core';
import { globalTypes, globalValues } from './shared/args.js';
const xmlModel = `<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" vertex="1" parent="1" value="Interval 1">
<mxGeometry x="380" y="20" width="140" height="30" as="geometry"/>
</mxCell>
<mxCell id="3" vertex="1" parent="1" value="Interval 2">
<mxGeometry x="200" y="80" width="380" height="30" as="geometry"/>
</mxCell>
<mxCell id="4" vertex="1" parent="1" value="Interval 3">
<mxGeometry x="40" y="140" width="260" height="30" as="geometry"/>
</mxCell>
<mxCell id="5" vertex="1" parent="1" value="Interval 4">
<mxGeometry x="120" y="200" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="6" vertex="1" parent="1" value="Interval 5">
<mxGeometry x="420" y="260" width="80" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" edge="1" source="2" target="3" parent="1" value="Transfer1">
<mxGeometry as="geometry">
<Array as="points">
<Object x="420" y="60"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="8" edge="1" source="2" target="6" parent="1" value="Transfer2">
<mxGeometry as="geometry" relative="1" y="0">
<Array as="points">
<Object x="600" y="60"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="9" edge="1" source="3" target="4" parent="1" value="Transfer3">
<mxGeometry as="geometry">
<Array as="points">
<Object x="260" y="120"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="10" edge="1" source="4" target="5" parent="1" value="Transfer4">
<mxGeometry as="geometry">
<Array as="points">
<Object x="200" y="180"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="11" edge="1" source="4" target="6" parent="1" value="Transfer5">
<mxGeometry as="geometry" relative="1" y="-10">
<Array as="points">
<Object x="460" y="155"/>
</Array>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>`;
export default {
title: 'Misc/CodecImport_mxGraph',
argTypes: {
...globalTypes,
},
args: {
...globalValues,
},
};
// This example demonstrates dynamically creating a graph from XML coming from mxGraph, as well as
// changing the default style for edges in-place.
const Template = ({ label, ...args }: Record<string, any>) => {
const div = document.createElement('div');
const container = document.createElement('div');
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.style.width = `${args.width}px`;
container.style.height = `${args.height}px`;
container.style.background = '#eeeeee';
container.style.border = '1px solid gray';
div.appendChild(container);
const graph = new Graph(container);
graph.centerZoom = false;
graph.setTooltips(false);
graph.setEnabled(false);
// Changes the default style for edges "in-place"
const style = graph.getStylesheet().getDefaultEdgeStyle();
style.edgeStyle = 'elbowEdgeStyle';
// Enables panning with left mouse button
const panningHandler = graph.getPlugin('PanningHandler') as PanningHandler;
panningHandler.useLeftButtonForPanning = true;
panningHandler.ignoreCell = true;
graph.container.style.cursor = 'move';
graph.setPanning(true);
InternalEvent.disableContextMenu(container);
new ModelXmlSerializer(graph.getDataModel()).import(xmlModel);
graph.resizeContainer = false;
// Adds zoom buttons in top, left corner
const buttons = document.createElement('div');
buttons.style.position = 'absolute';
buttons.style.overflow = 'visible';
const bs = graph.getBorderSizes();
buttons.style.top = `${container.offsetTop + bs.y}px`;
buttons.style.left = `${container.offsetLeft + bs.x}px`;
let left = 0;
const bw = 16;
const bh = 16;
function addButton(label: string, funct: () => void) {
const btn = document.createElement('div');
const labelNode = btn.ownerDocument.createTextNode(label);
btn.appendChild(labelNode);
btn.style.position = 'absolute';
btn.style.backgroundColor = 'transparent';
btn.style.border = '1px solid gray';
btn.style.textAlign = 'center';
btn.style.fontSize = '10px';
btn.style.cursor = 'pointer';
btn.style.width = `${bw}px`;
btn.style.height = `${bh}px`;
btn.style.left = `${left}px`;
btn.style.top = '0px';
InternalEvent.addListener(btn, 'click', function (evt: Event) {
funct();
InternalEvent.consume(evt);
});
left += bw;
buttons.appendChild(btn);
}
addButton('+', function () {
graph.zoomIn();
});
addButton('-', function () {
graph.zoomOut();
});
div.insertBefore(buttons, container);
return div;
};
export const Default = Template.bind({});

View File

@ -1,277 +0,0 @@
/**
* Copyright (c) 2006-2013, JGraph Ltd
*
* Codec
*/
import React from 'react';
import mxEvent from '../mxgraph/util/mxEvent';
import mxGraph from '../mxgraph/view/mxGraph';
import Codec from '../mxgraph/io/Codec';
import mxUtils from '../mxgraph/util/mxUtils';
import mxConstants from '../mxgraph/util/mxConstants';
import mxEdgeStyle from '../mxgraph/view/mxEdgeStyle';
// Contains a graph description which will be converted
const HTML_TEMPLATE = `
<h1>Codec</h1>
This example demonstrates dynamically creating a graph from XML and
encoding the model into XML, as well as changing the default style for
edges in-place. This graph is embedded in the page.
<div className="mxgraph" style="position:relative;overflow:auto;">
&lt;mxGraphModel&gt;&lt;root&gt;&lt;mxCell id="0"/&gt;&lt;mxCell
id="1" parent="0"/&gt;&lt;mxCell id="2" vertex="1" parent="1"
value="Interval 1"&gt;&lt;mxGeometry x="380" y="0" width="140"
height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="3"
vertex="1" parent="1" value="Interval 2"&gt;&lt;mxGeometry x="200"
y="80" width="380" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="4" vertex="1"
parent="1" value="Interval 3"&gt;&lt;mxGeometry x="40" y="140"
width="260" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="5" vertex="1" parent="1" value="Interval 4"&gt;&lt;mxGeometry
x="120" y="200" width="240" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="6" vertex="1"
parent="1" value="Interval 5"&gt;&lt;mxGeometry x="420" y="260"
width="80" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="7" edge="1" source="2" target="3" parent="1"
value="Transfer1"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="420"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="8" edge="1" source="2" target="6" parent="1"
value=""&gt;&lt;mxGeometry as="geometry" relative="1"
y="-30"&gt;&lt;Array as="points"&gt;&lt;Object x="600"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="9" edge="1" source="3" target="4" parent="1"
value="Transfer3"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="260"
y="120"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="10" edge="1" source="4" target="5" parent="1"
value="Transfer4"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="200"
y="180"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="11" edge="1" source="4" target="6" parent="1"
value="Transfer5"&gt;&lt;mxGeometry as="geometry" relative="1"
y="-10"&gt;&lt;Array as="points"&gt;&lt;Object x="460"
y="155"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;/root&gt;&lt;/mxGraphModel&gt;
</div>
This graph is embedded in the page.
<div
className="mxgraph"
style="position:relative;background:#eeeeee;border:1px solid gray;overflow:auto;width:400px;height:200px;"
>
&lt;mxGraphModel&gt;&lt;root&gt;&lt;mxCell id="0"/&gt;&lt;mxCell
id="1" parent="0"/&gt;&lt;mxCell id="2" vertex="1" parent="1"
value="Interval 1"&gt;&lt;mxGeometry x="380" y="0" width="140"
height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="3"
vertex="1" parent="1" value="Interval 2"&gt;&lt;mxGeometry x="200"
y="80" width="380" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="4" vertex="1"
parent="1" value="Interval 3"&gt;&lt;mxGeometry x="40" y="140"
width="260" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="5" vertex="1" parent="1" value="Interval 4"&gt;&lt;mxGeometry
x="120" y="200" width="240" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="6" vertex="1"
parent="1" value="Interval 5"&gt;&lt;mxGeometry x="420" y="260"
width="80" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="7" edge="1" source="2" target="3" parent="1"
value="Transfer1"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="420"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="8" edge="1" source="2" target="6" parent="1"
value=""&gt;&lt;mxGeometry as="geometry" relative="1"
y="-30"&gt;&lt;Array as="points"&gt;&lt;Object x="600"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="9" edge="1" source="3" target="4" parent="1"
value="Transfer3"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="260"
y="120"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="10" edge="1" source="4" target="5" parent="1"
value="Transfer4"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="200"
y="180"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="11" edge="1" source="4" target="6" parent="1"
value="Transfer5"&gt;&lt;mxGeometry as="geometry" relative="1"
y="-10"&gt;&lt;Array as="points"&gt;&lt;Object x="460"
y="155"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;/root&gt;&lt;/mxGraphModel&gt;
</div>
This graph is embedded in the page.
<div
className="mxgraph"
style="position:relative;background:#eeeeee;border:6px solid gray;overflow:auto;width:400px;height:200px;"
>
&lt;mxGraphModel&gt;&lt;root&gt;&lt;mxCell id="0"/&gt;&lt;mxCell
id="1" parent="0"/&gt;&lt;mxCell id="2" vertex="1" parent="1"
value="Interval 1"&gt;&lt;mxGeometry x="380" y="20" width="140"
height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="3"
vertex="1" parent="1" value="Interval 2"&gt;&lt;mxGeometry x="200"
y="80" width="380" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="4" vertex="1"
parent="1" value="Interval 3"&gt;&lt;mxGeometry x="40" y="140"
width="260" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="5" vertex="1" parent="1" value="Interval 4"&gt;&lt;mxGeometry
x="120" y="200" width="240" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="6" vertex="1"
parent="1" value="Interval 5"&gt;&lt;mxGeometry x="420" y="260"
width="80" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="7" edge="1" source="2" target="3" parent="1"
value="Transfer1"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="420"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="8" edge="1" source="2" target="6" parent="1"
value="Transfer2"&gt;&lt;mxGeometry as="geometry" relative="1"
y="0"&gt;&lt;Array as="points"&gt;&lt;Object x="600"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="9" edge="1" source="3" target="4" parent="1"
value="Transfer3"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="260"
y="120"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="10" edge="1" source="4" target="5" parent="1"
value="Transfer4"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="200"
y="180"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="11" edge="1" source="4" target="6" parent="1"
value="Transfer5"&gt;&lt;mxGeometry as="geometry" relative="1"
y="-10"&gt;&lt;Array as="points"&gt;&lt;Object x="460"
y="155"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;/root&gt;&lt;/mxGraphModel&gt;
</div>
This graph is embedded in the page.
<div
className="mxgraph"
style="position:relative;overflow:hidden;border:6px solid gray;"
>
&lt;mxGraphModel&gt;&lt;root&gt;&lt;mxCell id="0"/&gt;&lt;mxCell
id="1" parent="0"/&gt;&lt;mxCell id="2" vertex="1" parent="1"
value="Interval 1"&gt;&lt;mxGeometry x="380" y="20" width="140"
height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="3"
vertex="1" parent="1" value="Interval 2"&gt;&lt;mxGeometry x="200"
y="80" width="380" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="4" vertex="1"
parent="1" value="Interval 3"&gt;&lt;mxGeometry x="40" y="140"
width="260" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="5" vertex="1" parent="1" value="Interval 4"&gt;&lt;mxGeometry
x="120" y="200" width="240" height="30"
as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell id="6" vertex="1"
parent="1" value="Interval 5"&gt;&lt;mxGeometry x="420" y="260"
width="80" height="30" as="geometry"/&gt;&lt;/mxCell&gt;&lt;mxCell
id="7" edge="1" source="2" target="3" parent="1"
value="Transfer1"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="420"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="8" edge="1" source="2" target="6" parent="1"
value="Transfer2"&gt;&lt;mxGeometry as="geometry" relative="1"
y="0"&gt;&lt;Array as="points"&gt;&lt;Object x="600"
y="60"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="9" edge="1" source="3" target="4" parent="1"
value="Transfer3"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="260"
y="120"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="10" edge="1" source="4" target="5" parent="1"
value="Transfer4"&gt;&lt;mxGeometry as="geometry"&gt;&lt;Array
as="points"&gt;&lt;Object x="200"
y="180"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;mxCell
id="11" edge="1" source="4" target="6" parent="1"
value="Transfer5"&gt;&lt;mxGeometry as="geometry" relative="1"
y="-10"&gt;&lt;Array as="points"&gt;&lt;Object x="460"
y="155"/&gt;&lt;/Array&gt;&lt;/mxGeometry&gt;&lt;/mxCell&gt;&lt;/root&gt;&lt;/mxGraphModel&gt;
</div>
This graph is embedded in the page.`
const divs = document.getElementsByTagName('*');
for (let i = 0; i < divs.length; i += 1) {
if (divs[i].className.toString().indexOf('mxgraph') >= 0) {
(function(container) {
const xml = mxUtils.getTextContent(container);
const xmlDocument = mxUtils.parseXml(xml);
if (
xmlDocument.documentElement != null &&
xmlDocument.documentElement.nodeName === 'mxGraphModel'
) {
const decoder = new Codec(xmlDocument);
const node = xmlDocument.documentElement;
container.innerHTML = '';
const graph = new mxGraph(container);
graph.centerZoom = false;
graph.setTooltips(false);
graph.setEnabled(false);
// Changes the default style for edges "in-place"
const style = graph.getStylesheet().getDefaultEdgeStyle();
style.edge = mxEdgeStyle.ElbowConnector;
// Enables panning with left mouse button
graph.getPlugin('PanningHandler').useLeftButtonForPanning = true;
graph.getPlugin('PanningHandler').ignoreCell = true;
graph.container.style.cursor = 'move';
graph.setPanning(true);
if (divs[i].style.width === '' && divs[i].style.height === '') {
graph.resizeContainer = true;
} else {
// Adds border for fixed size boxes
graph.border = 20;
}
decoder.decode(node, graph.getDataModel());
graph.resizeContainer = false;
// Adds zoom buttons in top, left corner
const buttons = document.createElement('div');
buttons.style.position = 'absolute';
buttons.style.overflow = 'visible';
const bs = graph.getBorderSizes();
buttons.style.top = `${container.offsetTop + bs.y}px`;
buttons.style.left = `${container.offsetLeft + bs.x}px`;
let left = 0;
const bw = 16;
const bh = 16;
function addButton(label, funct) {
const btn = document.createElement('div');
mxUtils.write(btn, label);
btn.style.position = 'absolute';
btn.style.backgroundColor = 'transparent';
btn.style.border = '1px solid gray';
btn.style.textAlign = 'center';
btn.style.fontSize = '10px';
btn.style.cursor = 'hand';
btn.style.width = `${bw}px`;
btn.style.height = `${bh}px`;
btn.style.left = `${left}px`;
btn.style.top = '0px';
mxEvent.addListener(btn, 'click', function(evt) {
funct();
mxEvent.consume(evt);
});
left += bw;
buttons.appendChild(btn);
}
addButton('+', function() {
graph.zoomIn();
});
addButton('-', function() {
graph.zoomOut();
});
if (container.nextSibling != null) {
container.parentNode.insertBefore(buttons, container.nextSibling);
} else {
container.appendChild(buttons);
}
}
})(divs[i]);
}
}

View File

@ -508,6 +508,7 @@ Be aware of the properties that have been renamed or whose value types have chan
**Migration example** **Migration example**
If you want to write code to migrate mxGraph to maxGraph style, you can have a look at `packages/core/src/serialization/codecs/mxGraph/utils.ts`.
```js ```js
// Before // Before