Repository: maxGraph/maxGraph (ts)

Issue:
Add a way to not use default style properties when computing cell style
### Current state
In mxGraph, it was possible to configure a style for a cell and to explicitly state to not merge/consider the properties of the default style (which is done by default).
This is not documented in the mxGraph API, AFAIK there is no explicit example using the feature and it is quite hidden in the code.

While reviewing the mxGraph@4.2.2 code to compare with the migrated code in `StyleSheet.getCellStyle` to prepare #152, I realized that the feature is missing in maxGraph.


Here is the usual mxGraph way to configure styles that involve the default style
```javascript
// Merge properties of the default style and 2 custom styles
'style1;style2;prop1=value1;.....'
// No additional styles, only merge properties from the default style
'prop1=value1;.....'
```

To not merge properties of the default style, the style string must start with `;` (semicolon). Not obvious!
```javascript
';style1;style2;prop1=value1;.....'
```

Documented in https://github.com/jgraph/mxgraph/blob/v4.2.2/javascript/src/js/view/mxStylesheet.js#L33-L38
> To avoid the default style for a cell, add a leading semicolon to the style definition, eg. `;shadow=1`

Source: https://github.com/jgraph/mxgraph/blob/v4.2.2/javascript/src/js/view/mxStylesheet.js#L211-L217

```javascript
var style = defaultStyle;
if (style != null &&
      name.charAt(0) != ';')
{
      style = mxUtils.clone(style);
}
else
{
      style = new Object();
}
```

In maxGraph, ignoring the properties of the default style is not possible as there is no way to configure that.

Prior #152, the default style was always ignored if `baseStyleNames`. #152 now ensures that the default style is used for properties merge.


During the migration of the style management from a string representation (#82), an intermediate commit b51504d7bb972fe458882e906b2183587a6da57f proposed to ignore the default style when `baseStyleNames` was set to `null`. This wasn't very convenient and explicit, so this hasn't been kept in the final implementation.


```typescript
} else if (cellStyle.baseStyleName === null) {
      // baseStyleName is explicitly null, so don't use any default styles.
      style = {};
}
```


### Proposal

I suggest to introduce a new property `ignoreDefaultStyle` (whose default value is `false` to keep the existing behavior) in `CellStateStyle`.

This will be very easy to test with automatic tests introduced in #152 and the `Stylesheet.stories.js` example can be updated to demonstrate how a vertex is displayed when using this new property.

Also update implementation of #293.

Pull request description:
feat: let ignore the default style when computing the cell style
Update the Stylesheet story to demonstrate the new behavior.

The resources for migrating from mxGraph have been updated as well:
  - the migration guide
  - the codec that load the mxGraph model

## Notes

Closes #154

### Demo with the Stylesheet story

The "Interval 6" vertex is not using the default style.
So it is not rounded, is not using the same font (it uses maxGraph default font settings), doesn't have a perimeter (so the terminal point of the incoming edge is at the center of the vertex), ....

![Styles _ Stylesheet - Default ⋅ Storybook](https://github.com/maxGraph/maxGraph/assets/27200110/d68feac3-73ed-4463-a4fb-78b085b85114)



Patch:
diff --git a/packages/core/src/serialization/codecs/mxGraph/utils.ts b/packages/core/src/serialization/codecs/mxGraph/utils.ts
index 9807d400d..7a26b4e59 100644
--- a/packages/core/src/serialization/codecs/mxGraph/utils.ts
+++ b/packages/core/src/serialization/codecs/mxGraph/utils.ts
@@ -22,6 +22,7 @@ const fieldMapping = new Map<string, string>([['autosize', 'autoSize']]);
 
 export function convertStyleFromString(input: string) {
   const style: CellStyle = {};
+  input.startsWith(';') && (style.ignoreDefaultStyle = true);
 
   const elements = input
     .split(';')
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 49d4a26f7..5eb0edf5a 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -55,6 +55,15 @@ export type CellStyle = CellStateStyle & {
    * the value actually used is that defined in the last style declared in the array.
    */
   baseStyleNames?: string[];
+
+  /**
+   * If set to `true`, ignores the default style when computing the actual style for a cell.
+   * The {@link baseStyleNames} are still taken into account.
+   *
+   * @default false
+   * @since 0.11.0
+   */
+  ignoreDefaultStyle?: boolean;
 };
 
 export type CellStateStyle = {
diff --git a/packages/core/src/view/style/Stylesheet.ts b/packages/core/src/view/style/Stylesheet.ts
index ce52c644b..65a434528 100644
--- a/packages/core/src/view/style/Stylesheet.ts
+++ b/packages/core/src/view/style/Stylesheet.ts
@@ -159,8 +159,8 @@ export class Stylesheet {
    * Returns a {@link CellStateStyle} computed by merging the default style, styles referenced in the specified `baseStyleNames`
    * and the properties of the `cellStyle` parameter.
    *
-   * The properties are merged by taken the properties from various styles in the following order:
-   *   - default style
+   * The properties are merged by taking the properties from various styles in the following order:
+   *   - default style (if {@link CellStyle.ignoreDefaultStyle} is not set to `true`, otherwise it is ignored)
    *   - registered styles referenced in `baseStyleNames`, in the order of the array
    *   - `cellStyle` parameter
    *
@@ -171,21 +171,15 @@ export class Stylesheet {
    * @param defaultStyle Default style used as reference to compute the returned style.
    */
   getCellStyle(cellStyle: CellStyle, defaultStyle: CellStateStyle) {
-    let style: CellStateStyle;
-
+    let style = cellStyle.ignoreDefaultStyle ? {} : { ...defaultStyle };
     if (cellStyle.baseStyleNames) {
       // creates style with the given baseStyleNames. (merges from left to right)
-      style = cellStyle.baseStyleNames.reduce(
-        (acc, styleName) => {
-          return {
-            ...acc,
-            ...this.styles.get(styleName),
-          };
-        },
-        { ...defaultStyle }
-      );
-    } else {
-      style = { ...defaultStyle };
+      style = cellStyle.baseStyleNames.reduce((acc, styleName) => {
+        return {
+          ...acc,
+          ...this.styles.get(styleName),
+        };
+      }, style);
     }
 
     // Merges cellStyle into style
@@ -197,8 +191,9 @@ export class Stylesheet {
       }
     }
 
-    // Remove the 'baseStyleNames' that may have been copied from the cellStyle parameter to match the method signature
+    // Remove the specific CellStyle properties that may have been copied from the cellStyle parameter to match the method signature
     'baseStyleNames' in style && delete style.baseStyleNames;
+    'ignoreDefaultStyle' in style && delete style.ignoreDefaultStyle;
 
     return style;
   }
diff --git a/packages/html/stories/Stylesheet.stories.js b/packages/html/stories/Stylesheet.stories.js
index 944b82d5d..9b83346b1 100644
--- a/packages/html/stories/Stylesheet.stories.js
+++ b/packages/html/stories/Stylesheet.stories.js
@@ -123,6 +123,17 @@ const Template = ({ label, ...args }) => {
     const v3 = graph.insertVertex(parent, null, 'Interval 3', 200, 140, 360, 30);
     const v4 = graph.insertVertex(parent, null, 'Interval 4', 480, 200, 120, 30);
     const v5 = graph.insertVertex(parent, null, 'Interval 5', 60, 260, 400, 30);
+    const v6 = graph.insertVertex({
+      parent,
+      value: 'Interval 6 (ignore default style)',
+      position: [60, 360],
+      size: [500, 30],
+      style: {
+        ignoreDefaultStyle: true,
+        shape: 'rectangle',
+        strokeColor: 'black',
+      },
+    });
     const e1 = graph.insertEdge(parent, null, '1', v1, v2);
     e1.getGeometry().points = [new Point(160, 60)];
     const e2 = graph.insertEdge({
@@ -139,6 +150,7 @@ const Template = ({ label, ...args }) => {
     e4.getGeometry().points = [new Point(500, 180)];
     const e5 = graph.insertEdge(parent, null, '5', v3, v5);
     e5.getGeometry().points = [new Point(380, 180)];
+    const e6 = graph.insertEdge(parent, null, '6', v5, v6);
   });
 
   return container;
diff --git a/packages/website/docs/usage/migrate-from-mxgraph.md b/packages/website/docs/usage/migrate-from-mxgraph.md
index 99bbe4653..3ee764916 100644
--- a/packages/website/docs/usage/migrate-from-mxgraph.md
+++ b/packages/website/docs/usage/migrate-from-mxgraph.md
@@ -567,7 +567,7 @@ In `mxGraph`, to not merge properties of the default style, the style string mus
 This is documented in the [mxStylesheet documentation](https://github.com/jgraph/mxgraph/blob/v4.2.2/javascript/src/js/view/mxStylesheet.js#L33-L38).
 > To override the default style for a cell, add a leading semicolon to the style definition, e.g. ;shadow=1
 
-This is currently not supported in maxGraph: https://github.com/maxGraph/maxGraph/issues/154 "Add a way to not use default style properties when calculating cell styles".
+From version 0.11.0 of `maxGraph`, you can replicate this behavior by setting `ignoreDefaultStyle` to `true` in the `CellStyle` object.
 
 
 ### Codecs


Test patch:
diff --git a/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts b/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts
index d428a3cc5..3dee39a29 100644
--- a/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts
+++ b/packages/core/__tests__/serialization/codecs/mxgraph/utils.test.ts
@@ -16,7 +16,7 @@ limitations under the License.
 
 import { describe, expect, test } from '@jest/globals';
 import { convertStyleFromString } from '../../../../src/serialization/codecs/mxGraph/utils';
-import { CellStyle } from '../../../../src';
+import type { CellStyle } from '../../../../src';
 
 describe('convertStyleFromString', () => {
   test('Basic', () => {
@@ -36,10 +36,10 @@ describe('convertStyleFromString', () => {
   });
 
   test('With leading ;', () => {
-    // To update when implementing https://github.com/maxGraph/maxGraph/issues/154
-    expect(convertStyleFromString(';arcSize=4;endSize=5;')).toEqual({
+    expect(convertStyleFromString(';arcSize=4;endSize=5;')).toEqual(<CellStyle>{
       arcSize: 4,
       endSize: 5,
+      ignoreDefaultStyle: true,
     });
   });
 
diff --git a/packages/core/__tests__/view/style/StyleSheet.test.ts b/packages/core/__tests__/view/style/StyleSheet.test.ts
index cca66bf51..8e6810658 100644
--- a/packages/core/__tests__/view/style/StyleSheet.test.ts
+++ b/packages/core/__tests__/view/style/StyleSheet.test.ts
@@ -64,6 +64,7 @@ describe('putCellStyle', () => {
 describe('getCellStyle', () => {
   test.each([undefined, []])('baseStyleNames=%s', (baseStyleNames) => {
     const stylesheet = new Stylesheet();
+    const defaultStyle: CellStateStyle = { align: 'center', strokeColor: 'green' };
     const cellStyle = stylesheet.getCellStyle(
       {
         baseStyleNames,
@@ -71,7 +72,7 @@ describe('getCellStyle', () => {
         shape: 'triangle',
         strokeColor: 'yellow',
       },
-      { align: 'center', strokeColor: 'green' }
+      defaultStyle
     );
     expect(cellStyle).toStrictEqual(<CellStateStyle>{
       align: 'center', // from default
@@ -79,19 +80,21 @@ describe('getCellStyle', () => {
       shape: 'triangle',
       strokeColor: 'yellow', // from style
     });
+    expect(cellStyle).not.toBe(defaultStyle); // the default style is not modified
   });
 
   test('baseStyleNames set and related styles are registered', () => {
     const stylesheet = new Stylesheet();
     stylesheet.putCellStyle('style-1', { shape: 'triangle', fillColor: 'blue' });
 
+    const defaultStyle = { strokeColor: 'green', dashed: true };
     const cellStyle = stylesheet.getCellStyle(
       {
         baseStyleNames: ['style-1'],
         shape: 'cloud',
         strokeColor: 'yellow',
       },
-      { strokeColor: 'green', dashed: true }
+      defaultStyle
     );
     expect(cellStyle).toStrictEqual(<CellStateStyle>{
       dashed: true, // from default
@@ -99,6 +102,7 @@ describe('getCellStyle', () => {
       shape: 'cloud', // from style (override default and style-1)
       strokeColor: 'yellow',
     });
+    expect(cellStyle).not.toBe(defaultStyle); // the default style is not modified
   });
 
   test('baseStyleNames set and related styles are registered or not', () => {
@@ -266,4 +270,92 @@ describe('getCellStyle', () => {
       strokeColor: 'yellow',
     });
   });
+
+  describe('ignoreDefaultStyle', () => {
+    describe('set to true', () => {
+      test('no baseStyleNames', () => {
+        const stylesheet = new Stylesheet();
+        const cellStyle = stylesheet.getCellStyle(
+          {
+            ignoreDefaultStyle: true,
+            shape: 'cloud',
+            strokeColor: 'yellow',
+          },
+          { fillColor: 'red', strokeColor: 'green' }
+        );
+        expect(cellStyle).toStrictEqual({
+          shape: 'cloud',
+          strokeColor: 'yellow',
+        });
+      });
+
+      test('with baseStyleNames', () => {
+        const stylesheet = new Stylesheet();
+        stylesheet.putCellStyle('style-1', {
+          align: 'left',
+          shape: 'triangle',
+        });
+        const cellStyle = stylesheet.getCellStyle(
+          {
+            baseStyleNames: ['style-1'],
+            ignoreDefaultStyle: true,
+            rounded: true,
+            shape: 'cloud',
+            strokeColor: 'yellow',
+          },
+          { fillColor: 'red', strokeColor: 'green' }
+        );
+        expect(cellStyle).toStrictEqual({
+          align: 'left',
+          rounded: true,
+          shape: 'cloud',
+          strokeColor: 'yellow',
+        });
+      });
+    });
+
+    describe('set to false', () => {
+      test('no baseStyleNames', () => {
+        const stylesheet = new Stylesheet();
+        const cellStyle = stylesheet.getCellStyle(
+          {
+            ignoreDefaultStyle: false,
+            shape: 'cloud',
+            strokeColor: 'yellow',
+          },
+          { fillColor: 'red', strokeColor: 'green' }
+        );
+        expect(cellStyle).toStrictEqual({
+          fillColor: 'red',
+          shape: 'cloud',
+          strokeColor: 'yellow',
+        });
+      });
+
+      test('with baseStyleNames', () => {
+        const stylesheet = new Stylesheet();
+        stylesheet.putCellStyle('style-1', {
+          align: 'left',
+          shape: 'triangle',
+        });
+        const cellStyle = stylesheet.getCellStyle(
+          {
+            baseStyleNames: ['style-1'],
+            ignoreDefaultStyle: false,
+            rounded: true,
+            shape: 'cloud',
+            strokeColor: 'yellow',
+          },
+          { fillColor: 'red', strokeColor: 'green' }
+        );
+        expect(cellStyle).toStrictEqual({
+          align: 'left',
+          fillColor: 'red',
+          rounded: true,
+          shape: 'cloud',
+          strokeColor: 'yellow',
+        });
+      });
+    });
+  });
 });