From 00df840edffbf61e844169c49253c1d220500e60 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 26 Oct 2026 12:52:08 +0100 Subject: [PATCH 1/4] feat(dia.Paper): add filter option to model-based content area measurement `getContentArea()` with `useModelGeometry: true` returned the bbox of every cell in the graph, including the cells the paper hides through `cellVisibility`. The paper scroller, the navigator and "fit to content" helpers therefore sized themselves to content the paper does not show. - dia.Graph: `getBBox()` and `getCellsBBox()` accept a `filter(cell)` option - dia.Paper: `getContentArea()`, `getContentBBox()`, `getFitToContentArea()`, `fitToContent()` and `transformToFitContent()` accept a `filter(cell, paper)` option, used with `useModelGeometry: true` The paper's `cellVisibility` is deliberately not applied automatically: with virtual rendering it also culls cells outside the viewport, which would shrink the content area to the viewport. Refs clientIO/joint-plus#836 Co-Authored-By: Claude Opus 5.5 --- .changeset/graph-bbox-filter.md | 5 ++ .changeset/paper-content-area-filter.md | 5 ++ packages/joint-core/src/dia/Graph.mjs | 8 ++- packages/joint-core/src/dia/Paper.mjs | 8 ++- packages/joint-core/test/jointjs/dia/Paper.js | 72 +++++++++++++++++++ packages/joint-core/test/jointjs/graph.js | 46 ++++++++++++ packages/joint-core/test/ts/index.test.ts | 8 +++ packages/joint-core/types/dia.d.ts | 41 ++++++++--- 8 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 .changeset/graph-bbox-filter.md create mode 100644 .changeset/paper-content-area-filter.md diff --git a/.changeset/graph-bbox-filter.md b/.changeset/graph-bbox-filter.md new file mode 100644 index 0000000000..497f5805f9 --- /dev/null +++ b/.changeset/graph-bbox-filter.md @@ -0,0 +1,5 @@ +--- +"@joint/core": minor +--- + +dia.Graph - add `filter` option to `getBBox()` and `getCellsBBox()` diff --git a/.changeset/paper-content-area-filter.md b/.changeset/paper-content-area-filter.md new file mode 100644 index 0000000000..43d6af2df4 --- /dev/null +++ b/.changeset/paper-content-area-filter.md @@ -0,0 +1,5 @@ +--- +"@joint/core": minor +--- + +dia.Paper - add `filter` option to `getContentArea()` and the fit-to-content methods for `useModelGeometry` diff --git a/packages/joint-core/src/dia/Graph.mjs b/packages/joint-core/src/dia/Graph.mjs index 950ac0d7f8..7cf91dc88b 100644 --- a/packages/joint-core/src/dia/Graph.mjs +++ b/packages/joint-core/src/dia/Graph.mjs @@ -1471,15 +1471,17 @@ export const Graph = Model.extend({ }, // Return bounding box of all elements. - getBBox: function() { + getBBox: function(opt) { - return this.getCellsBBox(this.getCells()); + return this.getCellsBBox(this.getCells(), opt); }, // Return the bounding box of all cells in array provided. + // Only the cells passing the `filter` (if provided) are considered. getCellsBBox: function(cells, opt = {}) { - const { rotate = true } = opt; + const { rotate = true, filter } = opt; return util.toArray(cells).reduce(function(memo, cell) { + if (typeof filter === 'function' && !filter(cell)) return memo; const rect = cell.getBBox({ rotate }); if (!rect) return memo; if (memo) { diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index e8304b5c01..b08624e150 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -2587,7 +2587,13 @@ export const Paper = View.extend({ getContentArea: function(opt) { if (opt && opt.useModelGeometry) { - return this.model.getBBox() || new Rect(); + // Only the cells passing the `filter` (if provided) are considered. + const { filter } = opt; + const bboxOpt = {}; + if (typeof filter === 'function') { + bboxOpt.filter = (cell) => filter.call(this, cell, this); + } + return this.model.getBBox(bboxOpt) || new Rect(); } const graphLayerViews = this.getGraphLayerViews(); diff --git a/packages/joint-core/test/jointjs/dia/Paper.js b/packages/joint-core/test/jointjs/dia/Paper.js index 61c7cef349..10bb7496da 100644 --- a/packages/joint-core/test/jointjs/dia/Paper.js +++ b/packages/joint-core/test/jointjs/dia/Paper.js @@ -278,6 +278,78 @@ QUnit.module('joint.dia.Paper', function(hooks) { }); }); + QUnit.module('getContentArea() > options > filter', function() { + + QUnit.test('useModelGeometry = TRUE', function(assert) { + addCells(graph); + var [rect1, rect2] = graph.getElements(); + var calls = []; + var area = paper.getContentArea({ + useModelGeometry: true, + filter: function(cell, filterPaper) { + calls.push({ context: this, cell, paper: filterPaper }); + return cell.isElement(); + } + }); + assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 300 }, 'filtered cells are excluded'); + assert.equal(calls.length, 3, 'filter called for every cell'); + assert.ok(calls.every(call => call.context === paper && call.paper === paper), 'filter called with the paper'); + + area = paper.getContentArea({ useModelGeometry: true, filter: (cell) => cell === rect2 }); + assert.deepEqual(area.toJSON(), { x: 100, y: 100, width: 100, height: 100 }); + + area = paper.getContentArea({ useModelGeometry: true, filter: () => false }); + assert.ok(area instanceof g.Rect); + assert.deepEqual(area.toJSON(), { x: 0, y: 0, width: 0, height: 0 }, 'empty area when no cell passes the filter'); + + area = paper.getContentBBox({ useModelGeometry: true, filter: (cell) => cell === rect1 }); + assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 100, height: 100 }, 'getContentBBox() accepts the filter'); + }); + + QUnit.test('useModelGeometry = FALSE', function(assert) { + addCells(graph); + var filter = sinon.spy(() => false); + var area = paper.getContentArea({ filter }); + assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 400 }, 'filter is ignored'); + assert.ok(filter.notCalled); + }); + }); + + QUnit.module('fitToContent() > options > filter', function() { + + QUnit.test('useModelGeometry = TRUE', function(assert) { + addCells(graph); + var area = paper.fitToContent({ + useModelGeometry: true, + allowNewOrigin: 'any', + filter: (cell) => cell.isElement() + }); + assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 300 }); + assert.deepEqual(paper.getArea().toJSON(), { x: -100, y: -100, width: 300, height: 300 }); + + area = paper.getFitToContentArea({ + useModelGeometry: true, + allowNewOrigin: 'any', + filter: (cell) => cell.isElement() && cell.position().x > 0 + }); + assert.deepEqual(area.toJSON(), { x: 100, y: 100, width: 100, height: 100 }, 'getFitToContentArea() accepts the filter'); + }); + }); + + QUnit.module('transformToFitContent() > options > filter', function() { + + QUnit.test('useModelGeometry = TRUE', function(assert) { + addCells(graph); + paper.setDimensions(200, 200); + paper.transformToFitContent({ + useModelGeometry: true, + filter: (cell) => cell.isElement() && cell.position().x > 0 + }); + assert.deepEqual(paper.scale(), { sx: 2, sy: 2 }); + assert.deepEqual(paper.translate(), { tx: -200, ty: -200 }); + }); + }); + QUnit.module('fitToContent() > options', function() { [{ diff --git a/packages/joint-core/test/jointjs/graph.js b/packages/joint-core/test/jointjs/graph.js index c19b970652..53b12b5e62 100644 --- a/packages/joint-core/test/jointjs/graph.js +++ b/packages/joint-core/test/jointjs/graph.js @@ -2568,6 +2568,52 @@ QUnit.module('graph', function(hooks) { assert.equal(bbox.width, 130 - 50, 'bbox.width correct'); assert.equal(bbox.height, 130 - 50, 'bbox.height correct'); }); + + QUnit.test('filter option', function(assert) { + var r1 = new joint.shapes.standard.Rectangle({ + position: { x: 50, y: 50 }, + size: { width: 20, height: 20 }, + }); + var r2 = new joint.shapes.standard.Rectangle({ + position: { x: 100, y: 200 }, + size: { width: 20, height: 20 }, + }); + var r3 = new joint.shapes.standard.Rectangle({ + angle: 90, + position: { x: 200, y: 100 }, + size: { width: 40, height: 20 }, + }); + + this.graph.resetCells([r1, r2, r3]); + + var filteredCells = []; + var bbox = this.graph.getCellsBBox([r1, r2, r3], { + filter: function(cell) { + filteredCells.push(cell); + return cell !== r2; + } + }); + assert.deepEqual(filteredCells, [r1, r2, r3], 'filter called for every cell'); + assert.deepEqual(bbox.toJSON(), { x: 50, y: 50, width: 180, height: 80 }, 'filtered cells are excluded'); + + bbox = this.graph.getCellsBBox([r1, r2, r3], { + rotate: false, + filter: function(cell) { return cell !== r2; } + }); + assert.deepEqual(bbox.toJSON(), { x: 50, y: 50, width: 190, height: 70 }, 'rotate option is respected'); + + assert.equal( + this.graph.getCellsBBox([r1, r2, r3], { filter: function() { return false; } }), + null, + 'returns null when no cell passes the filter', + ); + + bbox = this.graph.getBBox({ filter: function(cell) { return cell === r2; } }); + assert.deepEqual(bbox.toJSON(), { x: 100, y: 200, width: 20, height: 20 }, 'graph.getBBox() accepts the filter'); + + bbox = this.graph.getBBox(); + assert.deepEqual(bbox.toJSON(), { x: 50, y: 50, width: 180, height: 170 }, 'graph.getBBox() without options considers all cells'); + }); }); QUnit.module('graph.findElementsUnderElement()', function() { diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 86a83743d5..78423f592f 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -160,6 +160,14 @@ const paper = new joint.dia.Paper({ paper.fitToContent({ padding: { top: 10 }, allowNewOrigin: false }); +// Measure the content only from the cells that pass the `filter`. +const isVisibleCell = (cell: joint.dia.Cell) => !cell.get('hidden'); +paper.fitToContent({ useModelGeometry: true, filter: isVisibleCell }); +paper.transformToFitContent({ useModelGeometry: true, filter(cell, filterPaper) { return this === filterPaper && isVisibleCell(cell); } }); +paper.getContentArea({ useModelGeometry: true, filter: isVisibleCell }).inflate(5); +graph.getBBox({ filter: isVisibleCell })?.inflate(5); +graph.getCellsBBox(graph.getElements(), { rotate: false, filter: isVisibleCell })?.inflate(5); + const eventOptions: joint.dia.Paper.Options[] = [ // `guard` is called without a view when the event did not hit a cell view. { guard: (_evt, view) => view?.model.isElement() ?? false } diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 7a4f574133..d826be79c3 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -276,6 +276,19 @@ export namespace Graph { strict?: boolean; } + interface BBoxOptions extends Cell.Options { + /** + * Whether to take the rotation of the cells into account. + * @default true + */ + rotate?: boolean; + /** + * Only the cells for which the function returns `true` + * contribute to the bounding box. + */ + filter?: (cell: Cell) => boolean; + } + interface SyncCellOptions extends Options { remove?: boolean; } @@ -527,9 +540,9 @@ export class Graph /** @deprecated use `findElementsUnderElement` instead */ findModelsUnderElement(element: Element, opt?: Graph.FindUnderElementOptions): Element[]; - getBBox(): g.Rect | null; + getBBox(opt?: Graph.BBoxOptions): g.Rect | null; - getCellsBBox(cells: Cell[], opt?: Cell.EmbeddableOptions): g.Rect | null; + getCellsBBox(cells: Cell[], opt?: Graph.BBoxOptions): g.Rect | null; hasActiveBatch(name?: string | string[]): boolean; @@ -1827,7 +1840,21 @@ export namespace Paper { disposeHidden?: boolean; } - interface TransformToFitContentOptions { + interface ContentAreaOptions { + /** + * Measure the content from the model geometry instead of the + * rendered views. + */ + useModelGeometry?: boolean; + /** + * Only the cells for which the function returns `true` contribute + * to the content area. Applies only with `useModelGeometry: true` + * (cells hidden by `cellVisibility` are never measured from the views). + */ + filter?: (this: Paper, cell: Cell, paper: Paper) => boolean; + } + + interface TransformToFitContentOptions extends ContentAreaOptions { padding?: Padding; preserveAspectRatio?: boolean; minScale?: number; @@ -1837,7 +1864,6 @@ export namespace Paper { maxScaleX?: number; maxScaleY?: number; scaleGrid?: number; - useModelGeometry?: boolean; fittingBBox?: BBox; contentArea?: BBox; verticalAlign?: 'top' | 'middle' | 'bottom'; @@ -1849,7 +1875,7 @@ export namespace Paper { */ type ScaleContentOptions = TransformToFitContentOptions; - interface FitToContentOptions { + interface FitToContentOptions extends ContentAreaOptions { gridWidth?: number; gridHeight?: number; padding?: Padding; @@ -1859,7 +1885,6 @@ export namespace Paper { minHeight?: number; maxWidth?: number; maxHeight?: number; - useModelGeometry?: boolean; contentArea?: BBox; /** * Shifts the grid anchor so the first cell starts at @@ -2094,9 +2119,9 @@ export class Paper extends mvc.View { getRestrictedArea(): g.Rect | null; getRestrictedArea(elementView: ElementView, x: number, y: number): g.Rect | null | Paper.PointConstraintCallback; - getContentArea(opt?: { useModelGeometry: boolean }): g.Rect; + getContentArea(opt?: Paper.ContentAreaOptions): g.Rect; - getContentBBox(opt?: { useModelGeometry: boolean }): g.Rect; + getContentBBox(opt?: Paper.ContentAreaOptions): g.Rect; findView(element: mvc.$SVGElement): T; From 4b3c5db5879b853c271585fcf463186d8a7a51a8 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 26 Oct 2026 12:57:03 +0100 Subject: [PATCH 2/4] refactor(dia.Paper): call the content area filter with the cell only Match the `filter(cell)` signature of `dia.Graph` and `Element.fitToChildren()` instead of binding the paper as `this` and passing it as an argument. Co-Authored-By: Claude Opus 5.5 --- packages/joint-core/src/dia/Paper.mjs | 7 +------ packages/joint-core/test/jointjs/dia/Paper.js | 14 ++++---------- packages/joint-core/test/ts/index.test.ts | 2 +- packages/joint-core/types/dia.d.ts | 2 +- 4 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index b08624e150..4b12b1e595 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -2588,12 +2588,7 @@ export const Paper = View.extend({ if (opt && opt.useModelGeometry) { // Only the cells passing the `filter` (if provided) are considered. - const { filter } = opt; - const bboxOpt = {}; - if (typeof filter === 'function') { - bboxOpt.filter = (cell) => filter.call(this, cell, this); - } - return this.model.getBBox(bboxOpt) || new Rect(); + return this.model.getBBox({ filter: opt.filter }) || new Rect(); } const graphLayerViews = this.getGraphLayerViews(); diff --git a/packages/joint-core/test/jointjs/dia/Paper.js b/packages/joint-core/test/jointjs/dia/Paper.js index 10bb7496da..961e63b881 100644 --- a/packages/joint-core/test/jointjs/dia/Paper.js +++ b/packages/joint-core/test/jointjs/dia/Paper.js @@ -283,17 +283,11 @@ QUnit.module('joint.dia.Paper', function(hooks) { QUnit.test('useModelGeometry = TRUE', function(assert) { addCells(graph); var [rect1, rect2] = graph.getElements(); - var calls = []; - var area = paper.getContentArea({ - useModelGeometry: true, - filter: function(cell, filterPaper) { - calls.push({ context: this, cell, paper: filterPaper }); - return cell.isElement(); - } - }); + var filter = sinon.spy((cell) => cell.isElement()); + var area = paper.getContentArea({ useModelGeometry: true, filter }); assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 300 }, 'filtered cells are excluded'); - assert.equal(calls.length, 3, 'filter called for every cell'); - assert.ok(calls.every(call => call.context === paper && call.paper === paper), 'filter called with the paper'); + assert.equal(filter.callCount, 3, 'filter called for every cell'); + assert.ok(filter.getCalls().every(call => call.args.length === 1), 'filter called with the cell only'); area = paper.getContentArea({ useModelGeometry: true, filter: (cell) => cell === rect2 }); assert.deepEqual(area.toJSON(), { x: 100, y: 100, width: 100, height: 100 }); diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 78423f592f..5d425dbd1f 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -163,7 +163,7 @@ paper.fitToContent({ padding: { top: 10 }, allowNewOrigin: false }); // Measure the content only from the cells that pass the `filter`. const isVisibleCell = (cell: joint.dia.Cell) => !cell.get('hidden'); paper.fitToContent({ useModelGeometry: true, filter: isVisibleCell }); -paper.transformToFitContent({ useModelGeometry: true, filter(cell, filterPaper) { return this === filterPaper && isVisibleCell(cell); } }); +paper.transformToFitContent({ useModelGeometry: true, filter: isVisibleCell }); paper.getContentArea({ useModelGeometry: true, filter: isVisibleCell }).inflate(5); graph.getBBox({ filter: isVisibleCell })?.inflate(5); graph.getCellsBBox(graph.getElements(), { rotate: false, filter: isVisibleCell })?.inflate(5); diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index d826be79c3..d8eb728f13 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1851,7 +1851,7 @@ export namespace Paper { * to the content area. Applies only with `useModelGeometry: true` * (cells hidden by `cellVisibility` are never measured from the views). */ - filter?: (this: Paper, cell: Cell, paper: Paper) => boolean; + filter?: (cell: Cell) => boolean; } interface TransformToFitContentOptions extends ContentAreaOptions { From f411d73866c2181d7e08f57950b479c64b20680c Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Mon, 26 Oct 2026 14:32:32 +0100 Subject: [PATCH 3/4] refactor(dia.Graph): drop the filter option of getBBox() and getCellsBBox() Callers of `getCellsBBox()` already choose the cells, so filtering them is `cells.filter(fn)`. `paper.getContentArea()` now filters the cells itself, which keeps the new option on the paper only, where `ui.PaperScroller` and `ui.Navigator` can pass it through their content options. Co-Authored-By: Claude Opus 5.5 --- .changeset/graph-bbox-filter.md | 5 --- packages/joint-core/src/dia/Graph.mjs | 8 ++-- packages/joint-core/src/dia/Paper.mjs | 10 ++++- packages/joint-core/test/jointjs/graph.js | 46 ----------------------- packages/joint-core/test/ts/index.test.ts | 2 - packages/joint-core/types/dia.d.ts | 17 +-------- 6 files changed, 13 insertions(+), 75 deletions(-) delete mode 100644 .changeset/graph-bbox-filter.md diff --git a/.changeset/graph-bbox-filter.md b/.changeset/graph-bbox-filter.md deleted file mode 100644 index 497f5805f9..0000000000 --- a/.changeset/graph-bbox-filter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/core": minor ---- - -dia.Graph - add `filter` option to `getBBox()` and `getCellsBBox()` diff --git a/packages/joint-core/src/dia/Graph.mjs b/packages/joint-core/src/dia/Graph.mjs index 7cf91dc88b..950ac0d7f8 100644 --- a/packages/joint-core/src/dia/Graph.mjs +++ b/packages/joint-core/src/dia/Graph.mjs @@ -1471,17 +1471,15 @@ export const Graph = Model.extend({ }, // Return bounding box of all elements. - getBBox: function(opt) { + getBBox: function() { - return this.getCellsBBox(this.getCells(), opt); + return this.getCellsBBox(this.getCells()); }, // Return the bounding box of all cells in array provided. - // Only the cells passing the `filter` (if provided) are considered. getCellsBBox: function(cells, opt = {}) { - const { rotate = true, filter } = opt; + const { rotate = true } = opt; return util.toArray(cells).reduce(function(memo, cell) { - if (typeof filter === 'function' && !filter(cell)) return memo; const rect = cell.getBBox({ rotate }); if (!rect) return memo; if (memo) { diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 4b12b1e595..4078d6c7ff 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -2587,8 +2587,14 @@ export const Paper = View.extend({ getContentArea: function(opt) { if (opt && opt.useModelGeometry) { - // Only the cells passing the `filter` (if provided) are considered. - return this.model.getBBox({ filter: opt.filter }) || new Rect(); + const { model: graph } = this; + const { filter } = opt; + if (typeof filter !== 'function') { + return graph.getBBox() || new Rect(); + } + // Only the cells passing the `filter` are considered. + const cells = graph.getCells().filter(cell => filter(cell)); + return graph.getCellsBBox(cells) || new Rect(); } const graphLayerViews = this.getGraphLayerViews(); diff --git a/packages/joint-core/test/jointjs/graph.js b/packages/joint-core/test/jointjs/graph.js index 53b12b5e62..c19b970652 100644 --- a/packages/joint-core/test/jointjs/graph.js +++ b/packages/joint-core/test/jointjs/graph.js @@ -2568,52 +2568,6 @@ QUnit.module('graph', function(hooks) { assert.equal(bbox.width, 130 - 50, 'bbox.width correct'); assert.equal(bbox.height, 130 - 50, 'bbox.height correct'); }); - - QUnit.test('filter option', function(assert) { - var r1 = new joint.shapes.standard.Rectangle({ - position: { x: 50, y: 50 }, - size: { width: 20, height: 20 }, - }); - var r2 = new joint.shapes.standard.Rectangle({ - position: { x: 100, y: 200 }, - size: { width: 20, height: 20 }, - }); - var r3 = new joint.shapes.standard.Rectangle({ - angle: 90, - position: { x: 200, y: 100 }, - size: { width: 40, height: 20 }, - }); - - this.graph.resetCells([r1, r2, r3]); - - var filteredCells = []; - var bbox = this.graph.getCellsBBox([r1, r2, r3], { - filter: function(cell) { - filteredCells.push(cell); - return cell !== r2; - } - }); - assert.deepEqual(filteredCells, [r1, r2, r3], 'filter called for every cell'); - assert.deepEqual(bbox.toJSON(), { x: 50, y: 50, width: 180, height: 80 }, 'filtered cells are excluded'); - - bbox = this.graph.getCellsBBox([r1, r2, r3], { - rotate: false, - filter: function(cell) { return cell !== r2; } - }); - assert.deepEqual(bbox.toJSON(), { x: 50, y: 50, width: 190, height: 70 }, 'rotate option is respected'); - - assert.equal( - this.graph.getCellsBBox([r1, r2, r3], { filter: function() { return false; } }), - null, - 'returns null when no cell passes the filter', - ); - - bbox = this.graph.getBBox({ filter: function(cell) { return cell === r2; } }); - assert.deepEqual(bbox.toJSON(), { x: 100, y: 200, width: 20, height: 20 }, 'graph.getBBox() accepts the filter'); - - bbox = this.graph.getBBox(); - assert.deepEqual(bbox.toJSON(), { x: 50, y: 50, width: 180, height: 170 }, 'graph.getBBox() without options considers all cells'); - }); }); QUnit.module('graph.findElementsUnderElement()', function() { diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 5d425dbd1f..db40305092 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -165,8 +165,6 @@ const isVisibleCell = (cell: joint.dia.Cell) => !cell.get('hidden'); paper.fitToContent({ useModelGeometry: true, filter: isVisibleCell }); paper.transformToFitContent({ useModelGeometry: true, filter: isVisibleCell }); paper.getContentArea({ useModelGeometry: true, filter: isVisibleCell }).inflate(5); -graph.getBBox({ filter: isVisibleCell })?.inflate(5); -graph.getCellsBBox(graph.getElements(), { rotate: false, filter: isVisibleCell })?.inflate(5); const eventOptions: joint.dia.Paper.Options[] = [ // `guard` is called without a view when the event did not hit a cell view. diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index d8eb728f13..ceead03d0b 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -276,19 +276,6 @@ export namespace Graph { strict?: boolean; } - interface BBoxOptions extends Cell.Options { - /** - * Whether to take the rotation of the cells into account. - * @default true - */ - rotate?: boolean; - /** - * Only the cells for which the function returns `true` - * contribute to the bounding box. - */ - filter?: (cell: Cell) => boolean; - } - interface SyncCellOptions extends Options { remove?: boolean; } @@ -540,9 +527,9 @@ export class Graph /** @deprecated use `findElementsUnderElement` instead */ findModelsUnderElement(element: Element, opt?: Graph.FindUnderElementOptions): Element[]; - getBBox(opt?: Graph.BBoxOptions): g.Rect | null; + getBBox(): g.Rect | null; - getCellsBBox(cells: Cell[], opt?: Graph.BBoxOptions): g.Rect | null; + getCellsBBox(cells: Cell[], opt?: Cell.EmbeddableOptions): g.Rect | null; hasActiveBatch(name?: string | string[]): boolean; From d5685835ce32d7c05a46d34a265e71ae7b6c93d1 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 29 Oct 2026 16:08:58 +0100 Subject: [PATCH 4/4] refactor(dia.Paper): nest the content area filter under useModelGeometry The filter applies only to the model-based measurement, so it now lives in `useModelGeometry: { filter }` instead of next to the flag. Co-Authored-By: Claude Opus 5.5 --- .changeset/paper-content-area-filter.md | 2 +- packages/joint-core/src/dia/Paper.mjs | 3 +- packages/joint-core/test/jointjs/dia/Paper.js | 41 ++++++++----------- packages/joint-core/test/ts/index.test.ts | 7 ++-- packages/joint-core/types/dia.d.ts | 18 ++++---- 5 files changed, 35 insertions(+), 36 deletions(-) diff --git a/.changeset/paper-content-area-filter.md b/.changeset/paper-content-area-filter.md index 43d6af2df4..f67d91444f 100644 --- a/.changeset/paper-content-area-filter.md +++ b/.changeset/paper-content-area-filter.md @@ -2,4 +2,4 @@ "@joint/core": minor --- -dia.Paper - add `filter` option to `getContentArea()` and the fit-to-content methods for `useModelGeometry` +dia.Paper - support `useModelGeometry: { filter }` in `getContentArea()` and the fit-to-content methods diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 4078d6c7ff..bb6d0cc4e7 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -2588,7 +2588,8 @@ export const Paper = View.extend({ if (opt && opt.useModelGeometry) { const { model: graph } = this; - const { filter } = opt; + // `useModelGeometry: true` or `useModelGeometry: { filter }` + const { filter } = isObject(opt.useModelGeometry) ? opt.useModelGeometry : {}; if (typeof filter !== 'function') { return graph.getBBox() || new Rect(); } diff --git a/packages/joint-core/test/jointjs/dia/Paper.js b/packages/joint-core/test/jointjs/dia/Paper.js index 961e63b881..c6d3101d0f 100644 --- a/packages/joint-core/test/jointjs/dia/Paper.js +++ b/packages/joint-core/test/jointjs/dia/Paper.js @@ -278,66 +278,61 @@ QUnit.module('joint.dia.Paper', function(hooks) { }); }); - QUnit.module('getContentArea() > options > filter', function() { + QUnit.module('getContentArea() > options > useModelGeometry > filter', function() { - QUnit.test('useModelGeometry = TRUE', function(assert) { + QUnit.test('filter', function(assert) { addCells(graph); var [rect1, rect2] = graph.getElements(); var filter = sinon.spy((cell) => cell.isElement()); - var area = paper.getContentArea({ useModelGeometry: true, filter }); + var area = paper.getContentArea({ useModelGeometry: { filter }}); assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 300 }, 'filtered cells are excluded'); assert.equal(filter.callCount, 3, 'filter called for every cell'); assert.ok(filter.getCalls().every(call => call.args.length === 1), 'filter called with the cell only'); - area = paper.getContentArea({ useModelGeometry: true, filter: (cell) => cell === rect2 }); + area = paper.getContentArea({ useModelGeometry: { filter: (cell) => cell === rect2 }}); assert.deepEqual(area.toJSON(), { x: 100, y: 100, width: 100, height: 100 }); - area = paper.getContentArea({ useModelGeometry: true, filter: () => false }); + area = paper.getContentArea({ useModelGeometry: { filter: () => false }}); assert.ok(area instanceof g.Rect); assert.deepEqual(area.toJSON(), { x: 0, y: 0, width: 0, height: 0 }, 'empty area when no cell passes the filter'); - area = paper.getContentBBox({ useModelGeometry: true, filter: (cell) => cell === rect1 }); + area = paper.getContentBBox({ useModelGeometry: { filter: (cell) => cell === rect1 }}); assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 100, height: 100 }, 'getContentBBox() accepts the filter'); }); - QUnit.test('useModelGeometry = FALSE', function(assert) { + QUnit.test('no filter', function(assert) { addCells(graph); - var filter = sinon.spy(() => false); - var area = paper.getContentArea({ filter }); - assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 400 }, 'filter is ignored'); - assert.ok(filter.notCalled); + var area = paper.getContentArea({ useModelGeometry: {}}); + assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 400 }, 'an object without filter measures every cell'); }); }); - QUnit.module('fitToContent() > options > filter', function() { + QUnit.module('fitToContent() > options > useModelGeometry > filter', function() { - QUnit.test('useModelGeometry = TRUE', function(assert) { + QUnit.test('filter', function(assert) { addCells(graph); var area = paper.fitToContent({ - useModelGeometry: true, - allowNewOrigin: 'any', - filter: (cell) => cell.isElement() + useModelGeometry: { filter: (cell) => cell.isElement() }, + allowNewOrigin: 'any' }); assert.deepEqual(area.toJSON(), { x: -100, y: -100, width: 300, height: 300 }); assert.deepEqual(paper.getArea().toJSON(), { x: -100, y: -100, width: 300, height: 300 }); area = paper.getFitToContentArea({ - useModelGeometry: true, - allowNewOrigin: 'any', - filter: (cell) => cell.isElement() && cell.position().x > 0 + useModelGeometry: { filter: (cell) => cell.isElement() && cell.position().x > 0 }, + allowNewOrigin: 'any' }); assert.deepEqual(area.toJSON(), { x: 100, y: 100, width: 100, height: 100 }, 'getFitToContentArea() accepts the filter'); }); }); - QUnit.module('transformToFitContent() > options > filter', function() { + QUnit.module('transformToFitContent() > options > useModelGeometry > filter', function() { - QUnit.test('useModelGeometry = TRUE', function(assert) { + QUnit.test('filter', function(assert) { addCells(graph); paper.setDimensions(200, 200); paper.transformToFitContent({ - useModelGeometry: true, - filter: (cell) => cell.isElement() && cell.position().x > 0 + useModelGeometry: { filter: (cell) => cell.isElement() && cell.position().x > 0 } }); assert.deepEqual(paper.scale(), { sx: 2, sy: 2 }); assert.deepEqual(paper.translate(), { tx: -200, ty: -200 }); diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index db40305092..9b481af9a8 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -162,9 +162,10 @@ paper.fitToContent({ padding: { top: 10 }, allowNewOrigin: false }); // Measure the content only from the cells that pass the `filter`. const isVisibleCell = (cell: joint.dia.Cell) => !cell.get('hidden'); -paper.fitToContent({ useModelGeometry: true, filter: isVisibleCell }); -paper.transformToFitContent({ useModelGeometry: true, filter: isVisibleCell }); -paper.getContentArea({ useModelGeometry: true, filter: isVisibleCell }).inflate(5); +paper.fitToContent({ useModelGeometry: { filter: isVisibleCell }}); +paper.transformToFitContent({ useModelGeometry: { filter: isVisibleCell }}); +paper.getContentArea({ useModelGeometry: { filter: isVisibleCell }}).inflate(5); +paper.getContentBBox({ useModelGeometry: true }).inflate(5); const eventOptions: joint.dia.Paper.Options[] = [ // `guard` is called without a view when the event did not hit a cell view. diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index ceead03d0b..69df72b199 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1827,20 +1827,22 @@ export namespace Paper { disposeHidden?: boolean; } - interface ContentAreaOptions { - /** - * Measure the content from the model geometry instead of the - * rendered views. - */ - useModelGeometry?: boolean; + interface ModelGeometryOptions { /** * Only the cells for which the function returns `true` contribute - * to the content area. Applies only with `useModelGeometry: true` - * (cells hidden by `cellVisibility` are never measured from the views). + * to the content area. */ filter?: (cell: Cell) => boolean; } + interface ContentAreaOptions { + /** + * Measure the content from the model geometry instead of the + * rendered views. Pass an object to configure the measurement. + */ + useModelGeometry?: boolean | ModelGeometryOptions; + } + interface TransformToFitContentOptions extends ContentAreaOptions { padding?: Padding; preserveAspectRatio?: boolean;