From 6f537527468586d94de010bc69b2f249b89b981c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 16:28:16 +0000 Subject: [PATCH 1/4] feat(joint-core): auto-emit 'resize' when host element CSS size changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dia.Paper` now attaches a ResizeObserver to its host `
` and re-emits the existing `'resize'` event when the host's computed size changes. This makes downstream subscribers — `GridLayerView`, `PaperScroller`, rulers, the React host — track CSS-relative sizing (`width: null`/`'100%'`/flex/ container queries) without manual `setDimensions()` calls. The observer self-skips when both `options.width` and `options.height` are numeric, preserving the explicit-size contract. New opt-out: `autoResizePaper: false`. Auto-emitted events carry `{ source: 'observer' }` as `data` so listeners can distinguish them from explicit `setDimensions()` emissions. https://claude.ai/code/session_013FyjBbiuXq4gr3ZRkT4Uvz --- packages/joint-core/src/dia/Paper.mjs | 51 ++++++++++++ packages/joint-core/test/jointjs/paper.js | 94 +++++++++++++++++++++++ packages/joint-core/types/dia.d.ts | 1 + 3 files changed, 146 insertions(+) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index affdcd98ad..7c55a4b50c 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -323,6 +323,11 @@ export const Paper = View.extend({ width: 800, height: 600, + // Track host-element CSS size changes via ResizeObserver and re-emit + // 'resize' so downstream listeners (grid, scroller, rulers) follow + // CSS-relative sizing (`null`/`'100%'`/etc.). No-op when both `width` + // and `height` are numeric — `setDimensions()` is the size authority then. + autoResizePaper: true, gridSize: 1, // Whether or not to draw the grid lines on the paper's DOM element. // e.g drawGrid: true, drawGrid: { color: 'red', thickness: 2 } @@ -668,6 +673,7 @@ export const Paper = View.extend({ this.cloneOptions(); this.render(); this._setDimensions(); + this._startObservingElementSize(); this.startListening(); // Mouse wheel events buffer @@ -2253,6 +2259,7 @@ export const Paper = View.extend({ onRemove: function() { + this._stopObservingElementSize(); this.freeze(); this._updates.disabled = true; //clean up all DOM elements/views to prevent memory leaks @@ -2281,6 +2288,15 @@ export const Paper = View.extend({ this._setDimensions(); const computedSize = this.getComputedSize(); this.trigger('resize', computedSize.width, computedSize.height, data); + // Prime the observer dedup state so the next ResizeObserver callback + // (triggered by the CSS write inside `_setDimensions`) doesn't re-emit. + if (this._elementResizeObserver) { + this._lastObservedSize = { width: computedSize.width, height: computedSize.height }; + } + // Re-evaluate observer attachment in case the user toggled between + // explicit-size and CSS-relative modes. + this._stopObservingElementSize(); + this._startObservingElementSize(); }, _setDimensions: function() { @@ -2295,6 +2311,41 @@ export const Paper = View.extend({ }); }, + _elementResizeObserver: null, + _lastObservedSize: null, + + _startObservingElementSize: function() { + if (this._elementResizeObserver) return; + if (!this.options.autoResizePaper) return; + if (typeof ResizeObserver === 'undefined') return; + const { width, height } = this.options; + // Explicit-size mode: `setDimensions()` is the sole source of 'resize'. + if (isNumber(width) && isNumber(height)) return; + + const size = this.getComputedSize(); + this._lastObservedSize = { width: size.width, height: size.height }; + + this._elementResizeObserver = new ResizeObserver(() => { + if (!this.el || !this._elementResizeObserver) return; + const next = this.getComputedSize(); + const prev = this._lastObservedSize; + if (prev && prev.width === next.width && prev.height === next.height) return; + this._lastObservedSize = { width: next.width, height: next.height }; + // The host already has its CSS-driven size; do not call + // `_setDimensions()` here — writing px values back would clobber CSS. + this.trigger('resize', next.width, next.height, { source: 'observer' }); + }); + this._elementResizeObserver.observe(this.el); + }, + + _stopObservingElementSize: function() { + if (this._elementResizeObserver) { + this._elementResizeObserver.disconnect(); + this._elementResizeObserver = null; + } + this._lastObservedSize = null; + }, + // Expand/shrink the paper to fit the content. // Alternatively signature function(opt) fitToContent: function(gridWidth, gridHeight, padding, opt) { diff --git a/packages/joint-core/test/jointjs/paper.js b/packages/joint-core/test/jointjs/paper.js index d35166fe7e..eb55b435c4 100644 --- a/packages/joint-core/test/jointjs/paper.js +++ b/packages/joint-core/test/jointjs/paper.js @@ -95,6 +95,100 @@ QUnit.module('paper', function(hooks) { resizeCbSpy.resetHistory(); }); + QUnit.module('autoResizePaper', function() { + + // Waits for the ResizeObserver to deliver its async callback. + // Two rAFs cover the spec's "delivered before the next paint" plus + // browser implementation slack. + function afterResizeObserverDelivery(callback) { + requestAnimationFrame(function() { + requestAnimationFrame(callback); + }); + } + + QUnit.test('fires resize when host CSS size changes', function(assert) { + var done = assert.async(); + var paper = this.paper; + // '100%' / '100%' makes the paper fill the container while + // keeping options non-numeric (i.e. observer-eligible). + $container.css({ width: '200px', height: '100px' }); + paper.setDimensions('100%', '100%'); + var spy = sinon.spy(); + paper.on('resize', spy); + afterResizeObserverDelivery(function() { + spy.resetHistory(); + $container.css({ width: '300px', height: '150px' }); + afterResizeObserverDelivery(function() { + assert.ok(spy.called, 'resize fires for host size change'); + var args = spy.lastCall.args; + assert.equal(args[0], 300, 'width reports host clientWidth'); + assert.equal(args[1], 150, 'height reports host clientHeight'); + assert.deepEqual(args[2], { source: 'observer' }, 'data carries observer source'); + done(); + }); + }); + }); + + QUnit.test('numeric dimensions skip the observer', function(assert) { + var done = assert.async(); + var paper = this.paper; + paper.setDimensions(200, 100); + var spy = sinon.spy(); + paper.on('resize', spy); + $container.css({ width: '500px', height: '500px' }); + afterResizeObserverDelivery(function() { + assert.ok(spy.notCalled, 'host CSS resize ignored in explicit-size mode'); + done(); + }); + }); + + QUnit.test('false disables the observer', function(assert) { + var done = assert.async(); + var paperEl = document.createElement('div'); + $container.append(paperEl); + var paper = new joint.dia.Paper({ + el: paperEl, + model: new joint.dia.Graph, + width: '100%', + height: '100%', + autoResizePaper: false + }); + var spy = sinon.spy(); + paper.on('resize', spy); + $container.css({ width: '321px', height: '123px' }); + afterResizeObserverDelivery(function() { + assert.ok(spy.notCalled, 'observer is not attached when autoResizePaper=false'); + paper.remove(); + done(); + }); + }); + + QUnit.test('disconnects on paper.remove()', function(assert) { + var paperEl = document.createElement('div'); + $container.append(paperEl); + var paper = new joint.dia.Paper({ + el: paperEl, + model: new joint.dia.Graph, + width: '100%', + height: '100%' + }); + assert.ok(paper._elementResizeObserver, 'observer attached on init'); + var disconnectSpy = sinon.spy(paper._elementResizeObserver, 'disconnect'); + paper.remove(); + assert.ok(disconnectSpy.calledOnce, 'disconnect called exactly once on remove'); + assert.notOk(paper._elementResizeObserver, 'observer reference cleared'); + }); + + QUnit.test('toggling between fixed and CSS-relative dimensions attaches/detaches observer', function(assert) { + var paper = this.paper; + assert.notOk(paper._elementResizeObserver, 'no observer with default numeric dims'); + paper.setDimensions('100%', '100%'); + assert.ok(paper._elementResizeObserver, 'observer attached after switching to CSS-relative'); + paper.setDimensions(100, 100); + assert.notOk(paper._elementResizeObserver, 'observer detached when both dims numeric again'); + }); + }); + }); QUnit.test('paper.addCell() number of sort()', function(assert) { diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 65884994cb..db6dd5f3a6 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1736,6 +1736,7 @@ export namespace Paper { // appearance width?: Dimension; height?: Dimension; + autoResizePaper?: boolean; drawGrid?: boolean | GridOptions | GridOptions[]; drawGridSize?: number | null; background?: BackgroundOptions; From e4e9732bf58cf805112d7dfcfda4500d30af3315 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Fri, 15 May 2026 19:46:09 +0200 Subject: [PATCH 2/4] update --- packages/joint-core/src/dia/Paper.mjs | 6 ------ packages/joint-core/types/dia.d.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 7c55a4b50c..674cdbde6a 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -323,11 +323,6 @@ export const Paper = View.extend({ width: 800, height: 600, - // Track host-element CSS size changes via ResizeObserver and re-emit - // 'resize' so downstream listeners (grid, scroller, rulers) follow - // CSS-relative sizing (`null`/`'100%'`/etc.). No-op when both `width` - // and `height` are numeric — `setDimensions()` is the size authority then. - autoResizePaper: true, gridSize: 1, // Whether or not to draw the grid lines on the paper's DOM element. // e.g drawGrid: true, drawGrid: { color: 'red', thickness: 2 } @@ -2316,7 +2311,6 @@ export const Paper = View.extend({ _startObservingElementSize: function() { if (this._elementResizeObserver) return; - if (!this.options.autoResizePaper) return; if (typeof ResizeObserver === 'undefined') return; const { width, height } = this.options; // Explicit-size mode: `setDimensions()` is the sole source of 'resize'. diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index db6dd5f3a6..65884994cb 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1736,7 +1736,6 @@ export namespace Paper { // appearance width?: Dimension; height?: Dimension; - autoResizePaper?: boolean; drawGrid?: boolean | GridOptions | GridOptions[]; drawGridSize?: number | null; background?: BackgroundOptions; From ef0963eba98656c4389515b09d464b587ea5f48f Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Fri, 15 May 2026 19:55:56 +0200 Subject: [PATCH 3/4] comment --- packages/joint-core/src/dia/Paper.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 674cdbde6a..2de5e9c96f 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -2321,6 +2321,13 @@ export const Paper = View.extend({ this._elementResizeObserver = new ResizeObserver(() => { if (!this.el || !this._elementResizeObserver) return; + // Re-read via getComputedSize() rather than the ResizeObserverEntry + // payload. `clientWidth/clientHeight` (the non-numeric branch of + // getComputedSize) is the padding box minus scrollbar — none of + // `contentBoxSize` / `borderBoxSize` / `contentRect` match exactly + // once the host has padding or border. Re-reading keeps the size + // we emit identical to every other `getComputedSize()` caller; the + // cost is one DOM read in a post-layout callback. const next = this.getComputedSize(); const prev = this._lastObservedSize; if (prev && prev.width === next.width && prev.height === next.height) return; From c22a30c89e491686f42bf7d4058ebabd953c437d Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Fri, 15 May 2026 20:04:17 +0200 Subject: [PATCH 4/4] update --- packages/joint-core/src/dia/Paper.mjs | 5 ----- packages/joint-core/test/jointjs/paper.js | 21 --------------------- 2 files changed, 26 deletions(-) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 2de5e9c96f..f70ce26ccd 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -2283,11 +2283,6 @@ export const Paper = View.extend({ this._setDimensions(); const computedSize = this.getComputedSize(); this.trigger('resize', computedSize.width, computedSize.height, data); - // Prime the observer dedup state so the next ResizeObserver callback - // (triggered by the CSS write inside `_setDimensions`) doesn't re-emit. - if (this._elementResizeObserver) { - this._lastObservedSize = { width: computedSize.width, height: computedSize.height }; - } // Re-evaluate observer attachment in case the user toggled between // explicit-size and CSS-relative modes. this._stopObservingElementSize(); diff --git a/packages/joint-core/test/jointjs/paper.js b/packages/joint-core/test/jointjs/paper.js index eb55b435c4..e5fd1db86e 100644 --- a/packages/joint-core/test/jointjs/paper.js +++ b/packages/joint-core/test/jointjs/paper.js @@ -142,27 +142,6 @@ QUnit.module('paper', function(hooks) { }); }); - QUnit.test('false disables the observer', function(assert) { - var done = assert.async(); - var paperEl = document.createElement('div'); - $container.append(paperEl); - var paper = new joint.dia.Paper({ - el: paperEl, - model: new joint.dia.Graph, - width: '100%', - height: '100%', - autoResizePaper: false - }); - var spy = sinon.spy(); - paper.on('resize', spy); - $container.css({ width: '321px', height: '123px' }); - afterResizeObserverDelivery(function() { - assert.ok(spy.notCalled, 'observer is not attached when autoResizePaper=false'); - paper.remove(); - done(); - }); - }); - QUnit.test('disconnects on paper.remove()', function(assert) { var paperEl = document.createElement('div'); $container.append(paperEl);