From 24e4eb1d9590d4f6ea010d71e45edc5af9197141 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Wed, 29 Jul 2026 11:19:23 +0300 Subject: [PATCH] feat(joint-core): add paper eventSurface option DOM content rendered into `paper.el` - a ruler, a gutter, a toolbar - is only half interactive. A press on it opens a blank interaction, but every other handler runs `guard()`, which rejects a target that is not on the paper's event surface, so the same content gets no `blank:pointerclick` and no hover events. `eventSurface` declares such a subtree part of the surface, and every handler then treats it as a blank area: new dia.Paper({ eventSurface: '.ruler' }) It takes a CSS selector (matched with `closest`, so it covers any number of subtrees), an element, an array of elements, or a `function(target) { ... }`. `guard` is consulted before the surface test, so the two compose in opposite directions: `eventSurface` opens a subtree, `guard` still closes single events within it - surface a ruler, then veto wheel events on it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/joint-core/src/dia/Paper.mjs | 33 +++++++- packages/joint-core/test/jointjs/paper.js | 91 +++++++++++++++++++++++ packages/joint-core/test/ts/index.test.ts | 10 +++ packages/joint-core/types/dia.d.ts | 3 + 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 5ad5458448..e8cdd33a12 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -342,6 +342,16 @@ export const Paper = View.extend({ return false; }, + // Extra DOM content inside `el` that counts as part of the paper's interaction + // surface. By default only the paper element itself and the SVG document do, so a + // press on HTML rendered into `el` (an overlay, a popup, a toolbar) is guarded and + // gets no `blank:pointerclick` and no hover events. Opting a subtree back in makes + // every paper handler treat it as it treats a blank area. + // A CSS selector (matched with `closest`, so it may cover any number of subtrees), + // an element, an array of elements, or a `function(target) { return boolean; }`. + // `guard` is consulted first, so single events can still be vetoed within a surface. + eventSurface: null, + highlighting: defaultHighlighting, // Prevent the default context menu from being displayed. @@ -3912,13 +3922,34 @@ export const Paper = View.extend({ return false; } - if (this.el === target || this.svg.contains(target)) { + if (this.el === target || this.svg.contains(target) || this.isEventSurface(target)) { return false; } return true; // Event guarded. Paper should not react on it in any way. }, + // Is `target` part of the paper's interaction surface? The SVG document always is; + // other DOM content inside `el` is only when the `eventSurface` option says so. + isEventSurface: function(target) { + + const { eventSurface } = this.options; + if (!eventSurface) return false; + // `target` is not guaranteed to be an element (e.g. the document). + if (!(target instanceof Element)) return false; + + if (isFunction(eventSurface)) { + return !!eventSurface.call(this, target); + } + + if (isString(eventSurface)) { + return !!target.closest(eventSurface); + } + + const surfaces = Array.isArray(eventSurface) ? eventSurface : [eventSurface]; + return surfaces.some((el) => el instanceof Element && el.contains(target)); + }, + setGridSize: function(gridSize) { const { options } = this; options.gridSize = gridSize; diff --git a/packages/joint-core/test/jointjs/paper.js b/packages/joint-core/test/jointjs/paper.js index a7a12dabd0..e5a83b6f1a 100644 --- a/packages/joint-core/test/jointjs/paper.js +++ b/packages/joint-core/test/jointjs/paper.js @@ -1240,6 +1240,97 @@ QUnit.module('paper', function(hooks) { assert.ok(diffX < 5 && diffY < 5, 'element should not have been moved'); }); + QUnit.test('eventSurface opts DOM content inside the paper back in', function(assert) { + + // HTML rendered into `paper.el` - a ruler, a gutter, a toolbar - is only half + // interactive. A press on it opens a blank interaction, but every other handler + // runs `guard()`, which rejects a target that is not on the paper's event surface, + // so the same content gets no `blank:pointerclick` and no hover. `eventSurface` + // declares the subtree part of the surface and the paper then treats all of it as + // a blank area. + const rulerEl = document.createElement('div'); + rulerEl.className = 'ruler'; + const tickEl = document.createElement('span'); + rulerEl.appendChild(tickEl); + this.paper.el.appendChild(rulerEl); + + const events = []; + this.paper.on('blank:pointerdown', () => events.push('pointerdown')); + this.paper.on('blank:pointermove', () => events.push('pointermove')); + this.paper.on('blank:pointerup', () => events.push('pointerup')); + this.paper.on('blank:pointerclick', () => events.push('pointerclick')); + this.paper.on('blank:mouseover', () => events.push('mouseover')); + + simulate.mouseover({ el: tickEl, clientX: 10, clientY: 10 }); + simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 }); + simulate.mouseup({ el: tickEl, clientX: 10, clientY: 10 }); + + assert.deepEqual(events, ['pointerdown', 'pointerup'], + 'without eventSurface the gesture is missing its click and hover'); + + // Pressing a descendant works: the selector is matched with `closest()`. + events.length = 0; + this.paper.options.eventSurface = '.ruler'; + + simulate.mouseover({ el: tickEl, clientX: 10, clientY: 10 }); + simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 }); + simulate.mouseup({ el: tickEl, clientX: 10, clientY: 10 }); + + assert.deepEqual(events, ['mouseover', 'pointerdown', 'pointerup', 'pointerclick'], + 'the surfaced content produces the complete blank gesture'); + + // A drag started on the ruler is tracked to completion. + events.length = 0; + simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 }); + simulate.mousemove({ el: tickEl, clientX: 100, clientY: 100 }); + simulate.mouseup({ el: tickEl, clientX: 100, clientY: 100 }); + + assert.deepEqual(events, ['pointerdown', 'pointermove', 'pointerup'], + 'a drag from the surfaced content is tracked (and is not a click)'); + + rulerEl.remove(); + }); + + QUnit.test('eventSurface accepts a selector, an element, an array or a predicate', function(assert) { + + const rulerEl = document.createElement('div'); + rulerEl.className = 'ruler'; + const tickEl = document.createElement('span'); + rulerEl.appendChild(tickEl); + this.paper.el.appendChild(rulerEl); + + const otherEl = document.createElement('div'); + this.paper.el.appendChild(otherEl); + + const { paper } = this; + const isSurface = (el) => paper.isEventSurface(el); + + paper.options.eventSurface = null; + assert.notOk(isSurface(tickEl), 'nothing is surfaced by default'); + + paper.options.eventSurface = '.ruler'; + assert.ok(isSurface(tickEl), 'selector: matches a descendant'); + assert.notOk(isSurface(otherEl), 'selector: does not match outside the subtree'); + + paper.options.eventSurface = rulerEl; + assert.ok(isSurface(tickEl), 'element: matches a descendant'); + assert.notOk(isSurface(otherEl), 'element: does not match outside the subtree'); + + paper.options.eventSurface = [rulerEl, otherEl]; + assert.ok(isSurface(tickEl) && isSurface(otherEl), 'array: matches either subtree'); + + paper.options.eventSurface = (target) => target === otherEl; + assert.ok(isSurface(otherEl), 'function: matches what it accepts'); + assert.notOk(isSurface(tickEl), 'function: rejects the rest'); + + // `target` is not always an element - `document` must not throw. + paper.options.eventSurface = '.ruler'; + assert.notOk(isSurface(document), 'a non-element target is never a surface'); + + rulerEl.remove(); + otherEl.remove(); + }); + QUnit.test('getContentArea()', function(assert) { assert.checkBboxApproximately(2/* +- */, this.paper.getContentArea(), { diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index 6d102e8797..03e7160595 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -160,6 +160,16 @@ const paper = new joint.dia.Paper({ paper.fitToContent({ padding: { top: 10 }, allowNewOrigin: false }); +const rulerEl = document.createElement('div'); +// `eventSurface` accepts a selector, an element, an array of elements or a predicate. +const eventSurfaceOptions: joint.dia.Paper.Options[] = [ + { eventSurface: '.ruler' }, + { eventSurface: rulerEl }, + { eventSurface: [rulerEl] }, + { eventSurface: (target) => target.classList.contains('ruler') }, + { eventSurface: null } +]; + const cellView = graph.getCells()[0].findView(paper); cellView.vel.addClass('test-class'); diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index a53bc8e25a..5613172f70 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1761,6 +1761,7 @@ export namespace Paper { allowLink?: ((linkView: LinkView, paper: Paper) => boolean) | null; // events guard?: (evt: Event, view: CellView) => boolean; + eventSurface?: string | DOMElement | DOMElement[] | ((target: DOMElement) => boolean) | null; preventContextMenu?: boolean; preventDefaultViewAction?: boolean; preventDefaultBlankAction?: boolean; @@ -2335,6 +2336,8 @@ export class Paper extends mvc.View { protected guard(evt: Event, view: CellView): boolean; + protected isEventSurface(target: EventTarget | null): boolean; + protected drawBackgroundImage(img: HTMLImageElement | null, opt?: { [key: string]: any }): void; protected updateBackgroundColor(color?: string): void;