diff --git a/CLAUDE.md b/CLAUDE.md index 8a039a8e16..57b50312c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,53 @@ npm run lint # Auto-fix linting issues npm run lint-check # Check linting without fixing ``` +### JSDoc Guidelines + +When writing or updating code, follow these JSDoc practices: + +**Public API Documentation:** +- **REQUIRED**: All public classes, methods, interfaces, and types must have JSDoc comments +- **REQUIRED**: All new public API elements must include the `@since` tag with the version number + - Infer the next version from `package.json` (current: `0.47.0-post` → next: `0.48.0`) + - Always confirm the target version with the user once per session before using it +- Include description, `@param` for parameters, `@return` for return values +- Document exceptions with `@throws` when applicable +- Use `@example` for complex APIs to show usage patterns +- Mark experimental APIs with `@experimental` tag +- Mark deprecated APIs with `@deprecated` and provide migration guidance + +**Internal Code Documentation:** +- **OPTIONAL**: Internal/private code may have JSDoc but it's not required +- Prefer self-documenting code with clear naming over excessive comments +- Add JSDoc for complex internal logic where the "why" isn't obvious +- Use inline comments for non-trivial implementation details + +**JSDoc Best Practices:** +- Keep descriptions concise but complete +- Use TypeScript types; avoid redundant type information in JSDoc (rely on `@param {Type}` when TypeScript inference isn't sufficient) +- Link to related types/methods using `{@link ClassName}` or `{@link methodName}` +- Document parameter constraints and valid ranges +- Specify units for numeric parameters (e.g., "duration in milliseconds") + +**Example:** +```typescript +/** + * Loads and renders a BPMN diagram from XML. + * + * @param xml The BPMN 2.0 XML content to parse and render + * @param options Optional configuration for rendering behavior + * @returns The loaded model information + * @throws {Error} If the XML is invalid or cannot be parsed + * @since 0.48.0 + * @example + * const bpmnVisualization = new BpmnVisualization({ container: 'diagram' }); + * bpmnVisualization.load('...'); + */ +public load(xml: string, options?: LoadOptions): LoadResult { + // implementation +} +``` + ### Documentation ```bash npm run docs # Generate all documentation diff --git a/README.md b/README.md index 9a0f5a04a8..361e544c85 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,6 @@ Please check the [__⏩ live environment__](https://cdn.statically.io/gh/process You will find their basic usage as well as detailed examples showing possible rendering customizations. -## 📂 Repository Structure - -The [dev](./dev) directory contains the source code for the **Load and Navigation demo** showcased on the [example site](https://cdn.statically.io/gh/process-analytics/bpmn-visualization-examples/master/examples/index.html). \ -This demo is also used for the PR previews of this repository. - - ## 🔆 Project Status `bpmn-visualization` is actively developed and maintained. @@ -192,6 +186,15 @@ The User documentation (with the feature list & the public API) is available in For more technical details and how-to, go to the `bpmn-visualization-examples` [repository](https://github.com/process-analytics/bpmn-visualization-examples/). +## 🚀 Repository Demo + +This repository includes a **Load and Navigation demo** located in the [dev](./dev) directory, which is also featured on the [example site](https://cdn.statically.io/gh/process-analytics/bpmn-visualization-examples/master/examples/index.html) and used for PR previews. + +The demo supports customization through URL query parameters. For instance, you can set a specific theme by passing `style.theme=light-blue`, or enable a custom icon painter with `renderer.iconPainter.use.custom=true`. + +For a complete list of configuration options, refer to the [source code](./dev/ts/shared/main.ts). + + ## 🔧 Contributing To contribute to `bpmn-visualization`, fork and clone this repository locally and commit your code on a separate branch. diff --git a/dev/ts/component/CustomIconPainter.ts b/dev/ts/component/CustomIconPainter.ts new file mode 100644 index 0000000000..0514c1046a --- /dev/null +++ b/dev/ts/component/CustomIconPainter.ts @@ -0,0 +1,48 @@ +/* +Copyright 2025 Bonitasoft S.A. + +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 { PaintParameter } from '../../../src/component/mxgraph/shape/render'; + +import { IconPainter } from '../../../src/component/mxgraph/shape/render'; + +// Taken from https://github.com/process-analytics/bpmn-visualization-examples/blob/v0.47.0/examples/custom-bpmn-theme/custom-user-task-icon/index.js#L9 +// The only difference is the fill color of the icon, which is not set here, to let the theme define it. +export class CustomIconPainter extends IconPainter { + // adapted from https://github.com/primer/octicons/blob/638c6683c96ec4b357576c7897be8f19c933c052/icons/person.svg + // use mxgraph-svg2shape to generate the code from the svg + override paintPersonIcon(paintParameter: PaintParameter): void { + const canvas = this.newBpmnCanvas(paintParameter, { height: 13, width: 12 }); + // respect the color of the current theme + canvas.setFillColor(paintParameter.iconStyleConfig.strokeColor); + + canvas.begin(); + canvas.moveTo(12, 13); + canvas.arcTo(1, 1, 0, 0, 1, 11, 14); + canvas.lineTo(1, 14); + canvas.arcTo(1, 1, 0, 0, 1, 0, 13); + canvas.lineTo(0, 12); + canvas.curveTo(0, 9.37, 4, 8, 4, 8); + canvas.curveTo(4, 8, 4.23, 8, 4, 8); + canvas.curveTo(3.16, 6.38, 3.06, 5.41, 3, 3); + canvas.curveTo(3.17, 0.59, 4.87, 0, 6, 0); + canvas.curveTo(7.13, 0, 8.83, 0.59, 9, 3); + canvas.curveTo(8.94, 5.41, 8.84, 6.38, 8, 8); + canvas.curveTo(8, 8, 12, 9.37, 12, 12); + canvas.lineTo(12, 13); + canvas.close(); + canvas.fill(); + } +} diff --git a/dev/ts/shared/main.ts b/dev/ts/shared/main.ts index 32da21fb3b..8af52ecff2 100644 --- a/dev/ts/shared/main.ts +++ b/dev/ts/shared/main.ts @@ -33,6 +33,7 @@ import type { import type { mxCell } from 'mxgraph'; import { FlowKind, ShapeBpmnElementKind } from '../../../src/bpmn-visualization'; +import { CustomIconPainter } from '../component/CustomIconPainter'; import { downloadAsPng, downloadAsSvg } from '../component/download'; import { DropFileUserInterface } from '../component/DropFileUserInterface'; import { SvgExporter } from '../component/SvgExporter'; @@ -240,8 +241,8 @@ function getFitOptionsFromParameters(config: BpmnVisualizationDemoConfiguration, function getRendererOptionsFromParameters(config: BpmnVisualizationDemoConfiguration, parameters: URLSearchParams): RendererOptions { const rendererOptions: RendererOptions = config.globalOptions.renderer ?? {}; - // Mapping between query parameter names and RendererOptions properties - const rendererParameterMappings: Record = { + // Mapping between query parameter names and RendererOptions boolean properties + const rendererParameterMappings: Record> = { 'renderer.ignore.bpmn.colors': 'ignoreBpmnColors', 'renderer.ignore.label.style': 'ignoreBpmnLabelStyles', 'renderer.ignore.activity.label.bounds': 'ignoreBpmnActivityLabelBounds', @@ -258,6 +259,12 @@ function getRendererOptionsFromParameters(config: BpmnVisualizationDemoConfigura } } + // Special handling for iconPainter as it requires an instance + if (parameters.get('renderer.iconPainter.use.custom') === 'true') { + rendererOptions.iconPainter = new CustomIconPainter(); + logStartup(`Setting renderer option 'iconPainter' with a CustomIconPainter instance`); + } + return rendererOptions; } diff --git a/src/component/BpmnVisualization.ts b/src/component/BpmnVisualization.ts index 704e006ac5..983a664f71 100644 --- a/src/component/BpmnVisualization.ts +++ b/src/component/BpmnVisualization.ts @@ -22,7 +22,7 @@ import type { Disposable } from './types'; import { htmlElement } from './helpers/dom-utils'; import { newBpmnRenderer } from './mxgraph/BpmnRenderer'; -import GraphConfigurator from './mxgraph/GraphConfigurator'; +import { createNewBpmnGraph } from './mxgraph/GraphConfigurator'; import { createNewNavigation } from './navigation'; import { newBpmnParser } from './parser/BpmnParser'; import { createNewBpmnElementsRegistry } from './registry/bpmn-elements-registry'; @@ -78,8 +78,7 @@ export class BpmnVisualization implements Disposable { constructor(options: GlobalOptions) { this.rendererOptions = options?.renderer; // graph configuration - const configurator = new GraphConfigurator(htmlElement(options?.container)); - this.graph = configurator.configure(); + this.graph = createNewBpmnGraph(htmlElement(options?.container), this.rendererOptions); // other configurations this.navigation = createNewNavigation(this.graph, options?.navigation); this.bpmnModelRegistry = new BpmnModelRegistry(); diff --git a/src/component/mxgraph/BpmnGraph.ts b/src/component/mxgraph/BpmnGraph.ts index 610ff6a473..692e802490 100644 --- a/src/component/mxgraph/BpmnGraph.ts +++ b/src/component/mxgraph/BpmnGraph.ts @@ -14,22 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { IconPainter } from './shape/render'; import type { mxCellRenderer, mxCellState, mxGraphView, mxPoint } from 'mxgraph'; import { BpmnCellRenderer } from './BpmnCellRenderer'; import { mxgraph } from './initializer'; -import { IconPainterProvider } from './shape/render'; + +/** + * Temporary storage for iconPainter during BpmnGraph construction. + * + * **Problem**: The mxGraph super constructor calls `createCellRenderer()` to set the cellRenderer property + * (via createGraphView at https://github.com/jgraph/mxgraph/blob/v4.2.2/javascript/src/js/view/mxGraph.js#L672). + * However, in JavaScript/TypeScript, instance fields (including constructor parameters declared as fields) + * are initialized AFTER the super() call completes. This means `this.iconPainter` is undefined when + * `createCellRenderer()` is called during super() construction. + * + * **Root cause**: mxGraph uses the **factory method pattern** for initialization. The mxGraph class is in + * charge of its own initialization by calling factory methods (`createCellRenderer()`, `createGraphView()`) + * instead of relying on injected collaborators. This makes it impossible to inject dependencies cleanly. + * + * **Why we can't use other approaches**: + * - Can't use `this` as WeakMap key: TypeScript/JavaScript doesn't allow accessing `this` before `super()` call + * - Can't use `this.container` as key: It's set AFTER createGraphView completes + * (https://github.com/jgraph/mxgraph/blob/v4.2.2/javascript/src/js/view/mxGraph.js#L691) + * - Can't use constructor parameter: Not accessible from `createCellRenderer()` method + * + * **Solution**: Use a module-level temporary variable. This is safe because JavaScript is single-threaded, + * so only one constructor can be executing at a time. The variable is set before `super()`, used during + * `createCellRenderer()`, then immediately cleaned up after construction. + * + * **Future**: If we migrate to maxGraph, this problem won't exist. maxGraph's BaseGraph accepts the + * CellRenderer via constructor options (dependency injection), eliminating the need for this workaround. + * See https://github.com/maxGraph/maxGraph/blob/cb16ce46d5b33df1ea7b2fbc8815c23420d3e658/packages/core/src/view/BaseGraph.ts#L36 + */ +let pendingIconPainter: IconPainter | undefined; export class BpmnGraph extends mxgraph.mxGraph { /** * @internal */ - constructor(container: HTMLElement) { + constructor(container: HTMLElement, iconPainter: IconPainter) { + // Store iconPainter in temporary variable BEFORE super() call + // This makes it available in createCellRenderer() which is called during super() construction + pendingIconPainter = iconPainter; + super(container); + if (this.container) { // ensure we don't have a select text cursor on label hover, see #294 this.container.style.cursor = 'default'; } + + // Clean up the temporary variable now that super() is complete + pendingIconPainter = undefined; } /** @@ -39,9 +76,16 @@ export class BpmnGraph extends mxgraph.mxGraph { return new BpmnGraphView(this); } + /** + * Called by mxGraph super constructor to create the cell renderer. + * + * This method is only called once during construction (by the mxGraph super constructor), + * so we retrieve the iconPainter from the module-level temporary variable. + * + * @internal + */ override createCellRenderer(): mxCellRenderer { - // in the future, the IconPainter could be configured at library initialization and the provider could be removed - return new BpmnCellRenderer(IconPainterProvider.get()); + return new BpmnCellRenderer(pendingIconPainter); } /** diff --git a/src/component/mxgraph/GraphConfigurator.ts b/src/component/mxgraph/GraphConfigurator.ts index 9e82a94c74..7ce86acce6 100644 --- a/src/component/mxgraph/GraphConfigurator.ts +++ b/src/component/mxgraph/GraphConfigurator.ts @@ -14,9 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { RendererOptions } from '../options'; + import { BpmnGraph } from './BpmnGraph'; import { registerEdgeMarkers, registerShapes } from './config/register-style-definitions'; import { StyleConfigurator } from './config/StyleConfigurator'; +import { IconPainter } from './shape/render'; + +/** + * @internal + */ +export function createNewBpmnGraph(container: HTMLElement, rendererOptions?: RendererOptions): BpmnGraph { + return new GraphConfigurator(new BpmnGraph(container, rendererOptions?.iconPainter ?? new IconPainter())).configure(); +} /** * Configure the {@link BpmnGraph} graph that can be used by the lib @@ -26,12 +36,8 @@ import { StyleConfigurator } from './config/StyleConfigurator'; *
  • markers * @internal */ -export default class GraphConfigurator { - private readonly graph: BpmnGraph; - - constructor(container: HTMLElement) { - this.graph = new BpmnGraph(container); - } +export class GraphConfigurator { + constructor(private readonly graph: BpmnGraph) {} configure(): BpmnGraph { this.configureGraph(); diff --git a/src/component/mxgraph/shape/render/icon-painter.ts b/src/component/mxgraph/shape/render/icon-painter.ts index 3790bbb6cc..b7f65f215a 100644 --- a/src/component/mxgraph/shape/render/icon-painter.ts +++ b/src/component/mxgraph/shape/render/icon-painter.ts @@ -940,22 +940,3 @@ function paintGearInnerCircle(canvas: BpmnCanvas, arcStartX: number, arcStartY: canvas.close(); canvas.fillAndStroke(); } - -/** - * Hold the instance of {@link IconPainter} used by the BPMN Theme. - * - * **WARN**: You may use it to customize the BPMN Theme as suggested in the examples. But be aware that the way the default BPMN theme can be modified is subject to change. - * - * @category BPMN Theme - * @experimental - */ -export class IconPainterProvider { - private static instance = new IconPainter(); - - static get(): IconPainter { - return this.instance; - } - static set(painter: IconPainter): void { - this.instance = painter; - } -} diff --git a/src/component/mxgraph/shape/render/index.ts b/src/component/mxgraph/shape/render/index.ts index 1b84e1a8cf..ee3d5db5e9 100644 --- a/src/component/mxgraph/shape/render/index.ts +++ b/src/component/mxgraph/shape/render/index.ts @@ -17,4 +17,4 @@ limitations under the License. // export types first, otherwise typedoc doesn't generate the subsequent doc correctly (no category and uses the file header instead of the actual TSDoc) export type * from './render-types'; export { BpmnCanvas, type BpmnCanvasConfiguration } from './BpmnCanvas'; -export { IconPainter, IconPainterProvider, type PaintParameter } from './icon-painter'; +export { IconPainter, type PaintParameter } from './icon-painter'; diff --git a/src/component/navigation.ts b/src/component/navigation.ts index 7d934f5db9..088537ebba 100644 --- a/src/component/navigation.ts +++ b/src/component/navigation.ts @@ -218,8 +218,8 @@ class ZoomSupport implements Disposable { this.mouseWheelListeners = []; } - private createMouseWheelZoomListener(performScaling: boolean) { - return (event: Event, up: boolean) => { + private createMouseWheelZoomListener(performScaling: boolean): MouseWheelListener { + return (event: Event, up: boolean): void => { if (mxEvent.isConsumed(event) || !(event instanceof MouseEvent)) { return; } diff --git a/src/component/options.ts b/src/component/options.ts index 0f6f0af3d1..254ac94318 100644 --- a/src/component/options.ts +++ b/src/component/options.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { IconPainter } from './mxgraph/shape/render'; + /** * Options to configure the `bpmn-visualization` initialization. * @category Initialization & Configuration @@ -197,6 +199,34 @@ export type ParserOptions = { * @since 0.35.0 */ export type RendererOptions = { + /** + * Custom {@link IconPainter} instance to use for rendering BPMN element icons. + * This allows you to customize how icons are rendered on tasks, events, and other BPMN elements. + * + * If not provided, a default {@link IconPainter} instance will be created automatically. + * + * @example + * ```typescript + * import { BpmnVisualization, IconPainter } from 'bpmn-visualization'; + * + * class CustomIconPainter extends IconPainter { + * paintPersonIcon(paintParameter) { + * // Custom rendering logic for user task icon + * } + * } + * + * const bpmnVisualization = new BpmnVisualization({ + * container: 'bpmn-container', + * renderer: { + * iconPainter: new CustomIconPainter() + * } + * }); + * ``` + * + * @since 0.48.0 + * @default an instance of the default {@link IconPainter} class + */ + iconPainter?: IconPainter; /** * If set to `true`, ignore the label bounds configuration defined in the BPMN diagram for all activities. * This forces the use of default label positioning instead of the bounds specified in the BPMN source. diff --git a/test/fixtures/bpmn/simple-start-userTask-end.bpmn b/test/fixtures/bpmn/simple-start-userTask-end.bpmn new file mode 100644 index 0000000000..bb75a77bc3 --- /dev/null +++ b/test/fixtures/bpmn/simple-start-userTask-end.bpmn @@ -0,0 +1,43 @@ + + + + Flow_1 + + + + Flow_1 + Flow_2 + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/integration/BpmnVisualization.test.ts b/test/integration/BpmnVisualization.test.ts index 720b9183ca..f5cf96d3f4 100644 --- a/test/integration/BpmnVisualization.test.ts +++ b/test/integration/BpmnVisualization.test.ts @@ -24,6 +24,7 @@ import { } from './helpers/bpmn-visualization-initialization'; import { allTestedFitTypes } from './helpers/fit-utils'; +import { IconPainter } from '@lib/component/mxgraph/shape/render'; import { ShapeBpmnElementKind } from '@lib/model/bpmn/internal'; import { readFileSync } from '@test/shared/file-helper'; @@ -39,6 +40,22 @@ describe('BpmnVisualization initialization', () => { }); }); +describe('BpmnVisualization constructor parameters', () => { + describe('Custom IconPainter', () => { + it('Custom IconPainter is called when rendering userTask', () => { + const customIconPainter = new IconPainter(); + const paintPersonIconSpy = jest.spyOn(customIconPainter, 'paintPersonIcon'); + + const bv = initializeBpmnVisualization('bpmn-custom-icon-painter', { + renderer: { iconPainter: customIconPainter }, + }); + bv.load(readFileSync('../fixtures/bpmn/simple-start-userTask-end.bpmn')); + + expect(paintPersonIconSpy).toHaveBeenCalled(); + }); + }); +}); + describe('BpmnVisualization API', () => { const bpmnVisualization = initializeBpmnVisualizationWithHtmlElement('bpmn-container', true);