From 835bfe7ce9fa6d24a8b58f5faf3f444fe85fa7be Mon Sep 17 00:00:00 2001 From: Thomas Bouffard <27200110+tbouffard@users.noreply.github.com> Date: Tue, 16 Jan 2024 11:03:56 +0100 Subject: [PATCH] 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. --- .../codecs/mxgraph/utils.test.ts | 81 +++++ .../serialization.xml.mxGraph.test.ts | 138 +++++++++ .../serialization/serialization.xml.test.ts | 74 +---- .../core/__tests__/serialization/utils.ts | 85 ++++++ packages/core/src/serialization/Codec.ts | 47 ++- .../core/src/serialization/CodecRegistry.ts | 66 +++-- .../core/src/serialization/ObjectCodec.ts | 2 - .../src/serialization/codecs/ModelCodec.ts | 4 +- .../core/src/serialization/codecs/index.ts | 2 + .../codecs/mxGraph/mxCellCodec.ts | 37 +++ .../codecs/mxGraph/mxGeometryCodec.ts | 54 ++++ .../src/serialization/codecs/mxGraph/utils.ts | 56 ++++ packages/core/src/serialization/register.ts | 7 + packages/html/stories/Codec.stories.ts | 179 +++++++++++ packages/html/stories/stashed/Codec.js | 277 ------------------ .../docs/usage/migrate-from-mxgraph.md | 1 + 16 files changed, 713 insertions(+), 397 deletions(-) create mode 100644 packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts create mode 100644 packages/core/__tests__/serialization/serialization.xml.mxGraph.test.ts create mode 100644 packages/core/__tests__/serialization/utils.ts create mode 100644 packages/core/src/serialization/codecs/mxGraph/mxCellCodec.ts create mode 100644 packages/core/src/serialization/codecs/mxGraph/mxGeometryCodec.ts create mode 100644 packages/core/src/serialization/codecs/mxGraph/utils.ts create mode 100644 packages/html/stories/Codec.stories.ts delete mode 100644 packages/html/stories/stashed/Codec.js diff --git a/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts b/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts new file mode 100644 index 000000000..d428a3cc5 --- /dev/null +++ b/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts @@ -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({ + baseStyleNames: ['rectangle', 'customRectangle'], + fontColor: 'yellow', + gradientColor: 'white', + }); + }); + + // renamed properties (see migration guide) + test('With renamed properties', () => { + // @ts-ignore + expect(convertStyleFromString('autosize=1')).toEqual({ + autoSize: 1, // FIX should be true + }); + }); +}); diff --git a/packages/core/__tests__/serialization/serialization.xml.mxGraph.test.ts b/packages/core/__tests__/serialization/serialization.xml.mxGraph.test.ts new file mode 100644 index 000000000..4c0929780 --- /dev/null +++ b/packages/core/__tests__/serialization/serialization.xml.mxGraph.test.ts @@ -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 = ` + + + + + + + + + + + + + + + + + + + `; + + 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 = ` + + + + + + +`; + + 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(` + + + + + + + +`); + + 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', + }, + }); + }); +}); diff --git a/packages/core/__tests__/serialization/serialization.xml.test.ts b/packages/core/__tests__/serialization/serialization.xml.test.ts index 41aa189c2..c7fa2c64a 100644 --- a/packages/core/__tests__/serialization/serialization.xml.test.ts +++ b/packages/core/__tests__/serialization/serialization.xml.test.ts @@ -15,15 +15,9 @@ limitations under the License. */ import { describe, expect, test } from '@jest/globals'; +import { ModelChecker } from './utils'; import { createGraphWithoutContainer } from '../utils'; -import { - Cell, - type CellStyle, - Geometry, - GraphDataModel, - ModelXmlSerializer, - Point, -} from '../../src'; +import { Cell, Geometry, GraphDataModel, ModelXmlSerializer, Point } from '../../src'; // inspired by VertexMixin.createVertex const newVertex = (id: string, value: string) => { @@ -47,68 +41,6 @@ const getParent = (model: GraphDataModel) => { 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 const xmlWithSingleVertex = ` @@ -201,6 +133,7 @@ describe('import before the export (reproduce https://github.com/maxGraph/maxGra const modelChecker = new ModelChecker(model); modelChecker.checkRootCells(); + modelChecker.checkCellsCount(3); modelChecker.expectIsVertex(model.getCell('B_#0'), 'rootNode', { geometry: new Geometry(100, 100, 100, 80), @@ -305,6 +238,7 @@ describe('import after export', () => { const modelChecker = new ModelChecker(model); modelChecker.checkRootCells(); + modelChecker.checkCellsCount(3); modelChecker.expectIsVertex(model.getCell('B_#0'), 'rootNode', { geometry: new Geometry(100, 100, 100, 80), diff --git a/packages/core/__tests__/serialization/utils.ts b/packages/core/__tests__/serialization/utils.ts new file mode 100644 index 000000000..c45694fb0 --- /dev/null +++ b/packages/core/__tests__/serialization/utils.ts @@ -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 ?? {}); + } +} diff --git a/packages/core/src/serialization/Codec.ts b/packages/core/src/serialization/Codec.ts index 142ee56c5..f64410f17 100644 --- a/packages/core/src/serialization/Codec.ts +++ b/packages/core/src/serialization/Codec.ts @@ -23,7 +23,6 @@ import Cell from '../view/cell/Cell'; import MaxLog from '../gui/MaxLog'; import { getFunctionName } from '../util/StringUtils'; import { importNode, isNode } from '../util/domUtils'; -import ObjectCodec from './ObjectCodec'; const createXmlDocument = () => { return document.implementation.createDocument('', '', null); @@ -415,35 +414,35 @@ class Codec { * and insertEdge on the parent and terminals, respectively. * Default is `true`. */ - decodeCell(node: Element, restoreStructures = true): Cell { - let cell = null; + decodeCell(node: Element, restoreStructures = true): 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 - // not return a codec then the node is the user object (an XML node - // that contains the mxCell, aka inversion). - let decoder = CodecRegistry.getCodec(node.nodeName); + // 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 + // that contains the mxCell, aka inversion). + let decoder = CodecRegistry.getCodec(node.nodeName); - // Tries to find the codec for the cell inside the user object. - // This assumes all node names inside the user object are either - // not registered or they correspond to a class for cells. - if (!this.isCellCodec(decoder)) { - let child = node.firstChild as Element; + // Tries to find the codec for the cell inside the user object. + // This assumes all node names inside the user object are either + // not registered or they correspond to a class for cells. + if (!this.isCellCodec(decoder)) { + let child = node.firstChild as Element; - while (child != null && !this.isCellCodec(decoder)) { - decoder = CodecRegistry.getCodec(child.nodeName); - child = child.nextSibling as Element; - } + while (child != null && !this.isCellCodec(decoder)) { + decoder = CodecRegistry.getCodec(child.nodeName); + child = child.nextSibling as Element; } + } - if (!this.isCellCodec(decoder)) { - decoder = CodecRegistry.getCodec(Cell); - } - cell = decoder?.decode(this, node); + if (!this.isCellCodec(decoder)) { + decoder = CodecRegistry.getCodec(Cell); + } + const cell = decoder?.decode(this, node); - if (restoreStructures) { - this.insertIntoGraph(cell); - } + if (restoreStructures) { + this.insertIntoGraph(cell); } return cell; } diff --git a/packages/core/src/serialization/CodecRegistry.ts b/packages/core/src/serialization/CodecRegistry.ts index d3f2b6722..1b15b9847 100644 --- a/packages/core/src/serialization/CodecRegistry.ts +++ b/packages/core/src/serialization/CodecRegistry.ts @@ -54,17 +54,18 @@ class CodecRegistry { 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 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) { const name = codec.getName(); CodecRegistry.codecs[name] = codec; const classname: string = codec.template.constructor.name; - if (classname !== name) { + if (registerAlias && 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; - if (constructor_ != null) { - let { name } = constructor_; - const tmp = CodecRegistry.aliases[name]; + // Equivalent of calling import { getFunctionName } from '../util/StringUtils'; + let name = + typeof constructorOrName === 'string' ? constructorOrName : constructorOrName.name; - if (tmp != null) { - name = tmp; - } + const tmp = CodecRegistry.aliases[name]; + 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. - if (codec == null) { - try { - codec = new ObjectCodec(new constructor_()); - CodecRegistry.register(codec); - } catch (e) { - // ignore - } + // Registers a new default codec for the given constructor if no codec has been previously defined. + if (codec == null) { + try { + codec = new ObjectCodec(new constructorOrName()); + CodecRegistry.register(codec); + } catch (e) { + // ignore } } 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) { - 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; } } diff --git a/packages/core/src/serialization/ObjectCodec.ts b/packages/core/src/serialization/ObjectCodec.ts index 66a1f1287..91d55bf1f 100644 --- a/packages/core/src/serialization/ObjectCodec.ts +++ b/packages/core/src/serialization/ObjectCodec.ts @@ -765,7 +765,6 @@ class ObjectCodec { } if (!this.isExcluded(obj, name, value, false)) { - // MaxLog.debug(mxUtils.getFunctionName(obj.constructor)+'.'+name+'='+value); obj[name] = value; } } @@ -858,7 +857,6 @@ class ObjectCodec { } else { obj.push(value); } - // MaxLog.debug('Decoded '+mxUtils.getFunctionName(obj.constructor)+'.'+fieldname+': '+value); } } diff --git a/packages/core/src/serialization/codecs/ModelCodec.ts b/packages/core/src/serialization/codecs/ModelCodec.ts index ce545e8cd..541bd50e0 100644 --- a/packages/core/src/serialization/codecs/ModelCodec.ts +++ b/packages/core/src/serialization/codecs/ModelCodec.ts @@ -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 . - * The sequence is wrapped-up in a node with the name root. + * 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`. */ encodeObject(enc: any, obj: Cell, node: Element) { const rootNode = enc.document.createElement('root'); diff --git a/packages/core/src/serialization/codecs/index.ts b/packages/core/src/serialization/codecs/index.ts index 257f71799..338b3f140 100644 --- a/packages/core/src/serialization/codecs/index.ts +++ b/packages/core/src/serialization/codecs/index.ts @@ -23,3 +23,5 @@ export * from './ModelCodec'; export * from './RootChangeCodec'; export * from './StylesheetCodec'; export * from './TerminalChangeCodec'; +export * from './mxGraph/mxCellCodec'; +export * from './mxGraph/mxGeometryCodec'; diff --git a/packages/core/src/serialization/codecs/mxGraph/mxCellCodec.ts b/packages/core/src/serialization/codecs/mxGraph/mxCellCodec.ts new file mode 100644 index 000000000..51ba487ae --- /dev/null +++ b/packages/core/src/serialization/codecs/mxGraph/mxCellCodec.ts @@ -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); + } + } +} diff --git a/packages/core/src/serialization/codecs/mxGraph/mxGeometryCodec.ts b/packages/core/src/serialization/codecs/mxGraph/mxGeometryCodec.ts new file mode 100644 index 000000000..28868e5db --- /dev/null +++ b/packages/core/src/serialization/codecs/mxGraph/mxGeometryCodec.ts @@ -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 + // + // + // + + const originalPoints = (obj as Geometry).points; + if (originalPoints) { + const points: Array = []; + 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; + } +} diff --git a/packages/core/src/serialization/codecs/mxGraph/utils.ts b/packages/core/src/serialization/codecs/mxGraph/utils.ts new file mode 100644 index 000000000..9807d400d --- /dev/null +++ b/packages/core/src/serialization/codecs/mxGraph/utils.ts @@ -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([['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; +} diff --git a/packages/core/src/serialization/register.ts b/packages/core/src/serialization/register.ts index a6fe60157..37fb6f155 100644 --- a/packages/core/src/serialization/register.ts +++ b/packages/core/src/serialization/register.ts @@ -25,6 +25,8 @@ import { GenericChangeCodec, GraphViewCodec, ModelCodec, + mxCellCodec, + mxGeometryCodec, RootChangeCodec, StylesheetCodec, TerminalChangeCodec, @@ -88,6 +90,11 @@ export const registerCoreCodecs = (force = false) => { CodecRegistry.register(new ObjectCodec({})); // Object CodecRegistry.register(new ObjectCodec([])); // Array + // mxGraph support + CodecRegistry.addAlias('mxGraphModel', 'GraphDataModel'); + CodecRegistry.register(new mxCellCodec(), false); + CodecRegistry.register(new mxGeometryCodec(), false); + isCoreCodecsRegistered = true; } }; diff --git a/packages/html/stories/Codec.stories.ts b/packages/html/stories/Codec.stories.ts new file mode 100644 index 000000000..bac1fac16 --- /dev/null +++ b/packages/html/stories/Codec.stories.ts @@ -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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +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) => { + 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({}); diff --git a/packages/html/stories/stashed/Codec.js b/packages/html/stories/stashed/Codec.js deleted file mode 100644 index 9abb922ef..000000000 --- a/packages/html/stories/stashed/Codec.js +++ /dev/null @@ -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 = ` -

Codec

- -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. -
- <mxGraphModel><root><mxCell id="0"/><mxCell - id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" - value="Interval 1"><mxGeometry x="380" y="0" 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=""><mxGeometry as="geometry" relative="1" - y="-30"><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> -
-This graph is embedded in the page. -
- <mxGraphModel><root><mxCell id="0"/><mxCell - id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" - value="Interval 1"><mxGeometry x="380" y="0" 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=""><mxGeometry as="geometry" relative="1" - y="-30"><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> -
-This graph is embedded in the page. -
- <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> -
-This graph is embedded in the page. -
- <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> -
-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]); - } -} diff --git a/packages/website/docs/usage/migrate-from-mxgraph.md b/packages/website/docs/usage/migrate-from-mxgraph.md index 2058bcde6..7ebd7b5d2 100644 --- a/packages/website/docs/usage/migrate-from-mxgraph.md +++ b/packages/website/docs/usage/migrate-from-mxgraph.md @@ -508,6 +508,7 @@ Be aware of the properties that have been renamed or whose value types have chan **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 // Before