Skip to content

Commit ca036f1

Browse files
kumilingusclaude
andcommitted
fix(joint-core): keep blank pointerdown compatible, add paper eventSurface
The previous commit made `pointerdown` run the full `guard()` even when the press hit no cell view. That is a breaking change: `guard()` rejects any target that is not on the paper's event surface, so HTML rendered into `paper.el` - a ruler, a gutter, a toolbar - stopped opening a blank interaction. The whole gesture went with it, because the document-level drag listeners are delegated from `pointerdown`. Restore the old answer while keeping a way to opt out. `guard()` is split in two: - `guardExplicit()` - decisions made about this very event: the right mouse button, the `guard` option, an `evt.data.guarded` flag. - `guard()` - unchanged: `guardExplicit()`, then judge the target itself (its tag name, its view, the event surface). A press that hit no cell view now consults only `guardExplicit()`. So the press opens a blank interaction as it always has, and `options.guard` can veto it - which was impossible before, since `guard()` was not consulted on that path at all. Behaviour is otherwise bit-exact: `GUARDED_TAG_NAMES` still judges the target, so a `<select>` in an overlay is not treated differently. The remaining asymmetry - such content gets `blank:pointerdown` but no `blank:pointerclick` and no hover - is now addressable rather than baked in. The new `eventSurface` paper option declares a DOM subtree part of the interaction 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 predicate. `guard` is consulted first, so single events can still be vetoed within a surface. Also types `guard`'s `view` parameter as optional - it has always been called without a view from `pointerclick`, `mouseover` and the other handlers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 051e410 commit ca036f1

5 files changed

Lines changed: 265 additions & 43 deletions

File tree

packages/joint-core/src/dia/Paper.mjs

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,16 @@ export const Paper = View.extend({
342342
return false;
343343
},
344344

345+
// Extra DOM content inside `el` that counts as part of the paper's interaction
346+
// surface. By default only the paper element itself and the SVG document do, so a
347+
// press on HTML rendered into `el` (an overlay, a popup, a toolbar) is guarded.
348+
// Opting a subtree back in makes the paper treat it as it treats a blank area
349+
// (`blank:pointerdown`, `blank:pointerclick`, `blank:mouseover`, ...).
350+
// A CSS selector (matched with `closest`, so it may cover any number of subtrees),
351+
// an element, an array of elements, or a `function(target) { return boolean; }`.
352+
// `guard` is consulted first, so single events can still be vetoed within a surface.
353+
eventSurface: null,
354+
345355
highlighting: defaultHighlighting,
346356

347357
// Prevent the default context menu from being displayed.
@@ -3476,14 +3486,16 @@ export const Paper = View.extend({
34763486
const view = this.findView(target);
34773487
const isContextMenu = (button === 2);
34783488

3479-
// Guard before any interaction starts, on blank areas too. Every other pointer
3480-
// handler (`pointerclick`, `pointerdblclick`, `contextMenuTrigger`, `mouseover`, …)
3481-
// guards unconditionally; guarding only the `view` branch let a press on non-SVG
3482-
// content inside `el` (an HTML overlay, a popup, a toolbar) start a blank
3483-
// interaction even though `guard()` rejects that target — and the matching
3484-
// `blank:pointerclick` was then guarded, so the events came out asymmetric.
3485-
// `contextmenu` stays exempt: `contextMenuTrigger()` runs its own guard.
3486-
if (!isContextMenu && this.guard(evt, view)) return;
3489+
if (!isContextMenu) {
3490+
// A press that did not hit a cell view is guarded too, so that DOM content
3491+
// inside `el` (an overlay, a popup, a toolbar) can opt out of opening a blank
3492+
// interaction. Only an explicit veto counts there: the full `guard()` also
3493+
// rejects anything outside the event surface, and such a press has always
3494+
// opened a blank interaction. Opt the content into `eventSurface` to have
3495+
// every other paper event treat it the same way.
3496+
// `contextmenu` is exempt: `contextMenuTrigger()` runs its own guard.
3497+
if (view ? this.guard(evt, view) : this.guardExplicit(evt, view)) return;
3498+
}
34873499

34883500
if (view) {
34893501

@@ -3896,6 +3908,34 @@ export const Paper = View.extend({
38963908
// Otherwise, it returns `false`.
38973909
guard: function(evt, view) {
38983910

3911+
const guarded = this.guardExplicit(evt, view);
3912+
if (guarded !== undefined) {
3913+
return guarded;
3914+
}
3915+
3916+
const { target } = evt;
3917+
3918+
if (this.GUARDED_TAG_NAMES.includes(target.tagName)) {
3919+
return true;
3920+
}
3921+
3922+
if (view && view.model && (view.model[CELL_MARKER])) {
3923+
return false;
3924+
}
3925+
3926+
if (this.el === target || this.svg.contains(target) || this.isEventSurface(target)) {
3927+
return false;
3928+
}
3929+
3930+
return true; // Event guarded. Paper should not react on it in any way.
3931+
},
3932+
3933+
// The part of `guard()` that reflects a decision made about this very event: the right
3934+
// mouse button, the `guard` option, an `evt.data.guarded` flag. Returns `undefined`
3935+
// when none of them has an opinion, leaving the answer to the caller - `guard()` then
3936+
// goes on to judge the target itself (its tag name, its view, the event surface).
3937+
guardExplicit: function(evt, view) {
3938+
38993939
if (evt.type === 'mousedown' && evt.button === 2) {
39003940
// handled as `contextmenu` type
39013941
return true;
@@ -3909,21 +3949,28 @@ export const Paper = View.extend({
39093949
return evt.data.guarded;
39103950
}
39113951

3912-
const { target } = evt;
3952+
return undefined;
3953+
},
39133954

3914-
if (this.GUARDED_TAG_NAMES.includes(target.tagName)) {
3915-
return true;
3916-
}
3955+
// Is `target` part of the paper's interaction surface? The SVG document always is;
3956+
// other DOM content inside `el` is only when the `eventSurface` option says so.
3957+
isEventSurface: function(target) {
39173958

3918-
if (view && view.model && (view.model[CELL_MARKER])) {
3919-
return false;
3959+
const { eventSurface } = this.options;
3960+
if (!eventSurface) return false;
3961+
// `target` is not guaranteed to be an element (e.g. the document).
3962+
if (!(target instanceof Element)) return false;
3963+
3964+
if (isFunction(eventSurface)) {
3965+
return !!eventSurface.call(this, target);
39203966
}
39213967

3922-
if (this.el === target || this.svg.contains(target)) {
3923-
return false;
3968+
if (isString(eventSurface)) {
3969+
return !!target.closest(eventSurface);
39243970
}
39253971

3926-
return true; // Event guarded. Paper should not react on it in any way.
3972+
const surfaces = Array.isArray(eventSurface) ? eventSurface : [eventSurface];
3973+
return surfaces.some((el) => el instanceof Element && el.contains(target));
39273974
},
39283975

39293976
setGridSize: function(gridSize) {

packages/joint-core/test/jointjs/paper.js

Lines changed: 148 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,13 +1240,13 @@ QUnit.module('paper', function(hooks) {
12401240
assert.ok(diffX < 5 && diffY < 5, 'element should not have been moved');
12411241
});
12421242

1243-
QUnit.test('pointerdown is guarded on blank areas too', function(assert) {
1243+
QUnit.test('a press on DOM content inside the paper can be guarded', function(assert) {
12441244

1245-
// A press on non-SVG content inside `paper.el` (an HTML overlay, popup or
1246-
// toolbar) must not start a blank interaction: `guard()` rejects such a target,
1247-
// and every other pointer handler honours that. `pointerdown` used to consult
1248-
// `guard()` only when a cell view was found, so `blank:pointerdown` fired for
1249-
// overlay content while the matching `blank:pointerclick` stayed guarded.
1245+
// `guard()` rejects a target that is not on the event surface, so an HTML overlay,
1246+
// popup or toolbar inside `paper.el` gets no `blank:pointerclick` and no hover
1247+
// events. A press on one has always opened a blank interaction regardless, and
1248+
// still does - but `pointerdown` now consults the `guard` option there too, so an
1249+
// overlay can opt out of it.
12501250
const overlayEl = document.createElement('div');
12511251
this.paper.el.appendChild(overlayEl);
12521252

@@ -1261,18 +1261,156 @@ QUnit.module('paper', function(hooks) {
12611261
simulate.mousedown({ el: overlayEl, clientX: 10, clientY: 10 });
12621262
simulate.mouseup({ el: overlayEl, clientX: 10, clientY: 10 });
12631263

1264-
assert.equal(blankPointerdownCount, 0,
1265-
'no blank:pointerdown for a press on HTML content inside the paper');
1264+
assert.equal(blankPointerdownCount, 1,
1265+
'a press on HTML content inside the paper opens a blank interaction');
12661266

1267-
// A genuine blank press (on the SVG) must still work.
1267+
// The `guard` option vetoes it.
1268+
this.paper.options.guard = function(evt) {
1269+
return overlayEl.contains(evt.target);
1270+
};
1271+
1272+
simulate.mousedown({ el: overlayEl, clientX: 10, clientY: 10 });
1273+
simulate.mouseup({ el: overlayEl, clientX: 10, clientY: 10 });
1274+
1275+
assert.equal(blankPointerdownCount, 1, 'the guard option prevents it');
1276+
1277+
// A genuine blank press (on the SVG) is unaffected by that guard.
12681278
simulate.mousedown({ el: this.paper.svg, clientX: 10, clientY: 10 });
12691279
simulate.mouseup({ el: this.paper.svg, clientX: 10, clientY: 10 });
12701280

1271-
assert.equal(blankPointerdownCount, 1, 'blank:pointerdown still fires on the SVG');
1281+
assert.equal(blankPointerdownCount, 2, 'blank:pointerdown still fires on the SVG');
1282+
1283+
// Only the `guard` option, `evt.data.guarded` and the right button speak for a
1284+
// press that hit no cell view. `GUARDED_TAG_NAMES` judges the target instead, and
1285+
// stays out of it: a <select> in an overlay opens a blank interaction like any
1286+
// other DOM content there, exactly as it always has.
1287+
this.paper.options.guard = null;
1288+
const selectEl = document.createElement('select');
1289+
overlayEl.appendChild(selectEl);
1290+
1291+
assert.equal(this.paper.guard({ type: 'mousedown', button: 0, target: selectEl }), true,
1292+
'guard() still rejects a <select>');
1293+
1294+
simulate.mousedown({ el: selectEl, clientX: 10, clientY: 10 });
1295+
simulate.mouseup({ el: selectEl, clientX: 10, clientY: 10 });
1296+
1297+
assert.equal(blankPointerdownCount, 3, 'a <select> in an overlay is not treated differently');
12721298

12731299
overlayEl.remove();
12741300
});
12751301

1302+
QUnit.test('eventSurface opts DOM content inside the paper back in', function(assert) {
1303+
1304+
// HTML rendered into `paper.el` - a ruler, a gutter, a toolbar - is only half
1305+
// interactive: a press opens a blank interaction, but every other handler runs the
1306+
// full `guard()`, which rejects a target that is not on the event surface. So the
1307+
// gesture comes out asymmetric: `blank:pointerdown` and the drag that follows it,
1308+
// but no `blank:pointerclick` and no hover. `eventSurface` declares the subtree
1309+
// part of the surface and the paper then treats all of it as a blank area.
1310+
const rulerEl = document.createElement('div');
1311+
rulerEl.className = 'ruler';
1312+
const tickEl = document.createElement('span');
1313+
rulerEl.appendChild(tickEl);
1314+
this.paper.el.appendChild(rulerEl);
1315+
1316+
const events = [];
1317+
this.paper.on('blank:pointerdown', () => events.push('pointerdown'));
1318+
this.paper.on('blank:pointermove', () => events.push('pointermove'));
1319+
this.paper.on('blank:pointerup', () => events.push('pointerup'));
1320+
this.paper.on('blank:pointerclick', () => events.push('pointerclick'));
1321+
this.paper.on('blank:mouseover', () => events.push('mouseover'));
1322+
1323+
simulate.mouseover({ el: tickEl, clientX: 10, clientY: 10 });
1324+
simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 });
1325+
simulate.mouseup({ el: tickEl, clientX: 10, clientY: 10 });
1326+
1327+
assert.deepEqual(events, ['pointerdown', 'pointerup'],
1328+
'without eventSurface the gesture is missing its click and hover');
1329+
1330+
// Pressing a descendant works: the selector is matched with `closest()`.
1331+
events.length = 0;
1332+
this.paper.options.eventSurface = '.ruler';
1333+
1334+
simulate.mouseover({ el: tickEl, clientX: 10, clientY: 10 });
1335+
simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 });
1336+
simulate.mouseup({ el: tickEl, clientX: 10, clientY: 10 });
1337+
1338+
assert.deepEqual(events, ['mouseover', 'pointerdown', 'pointerup', 'pointerclick'],
1339+
'the surfaced content produces the complete blank gesture');
1340+
1341+
// A drag started on the ruler is tracked to completion.
1342+
events.length = 0;
1343+
simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 });
1344+
simulate.mousemove({ el: tickEl, clientX: 100, clientY: 100 });
1345+
simulate.mouseup({ el: tickEl, clientX: 100, clientY: 100 });
1346+
1347+
assert.deepEqual(events, ['pointerdown', 'pointermove', 'pointerup'],
1348+
'a drag from the surfaced content is tracked (and is not a click)');
1349+
1350+
rulerEl.remove();
1351+
});
1352+
1353+
QUnit.test('eventSurface forms, and guard still wins', function(assert) {
1354+
1355+
const rulerEl = document.createElement('div');
1356+
rulerEl.className = 'ruler';
1357+
const tickEl = document.createElement('span');
1358+
rulerEl.appendChild(tickEl);
1359+
this.paper.el.appendChild(rulerEl);
1360+
1361+
const otherEl = document.createElement('div');
1362+
this.paper.el.appendChild(otherEl);
1363+
1364+
const { paper } = this;
1365+
const isSurface = (el) => paper.isEventSurface(el);
1366+
1367+
paper.options.eventSurface = null;
1368+
assert.notOk(isSurface(tickEl), 'nothing is surfaced by default');
1369+
1370+
paper.options.eventSurface = '.ruler';
1371+
assert.ok(isSurface(tickEl), 'selector: matches a descendant');
1372+
assert.notOk(isSurface(otherEl), 'selector: does not match outside the subtree');
1373+
1374+
paper.options.eventSurface = rulerEl;
1375+
assert.ok(isSurface(tickEl), 'element: matches a descendant');
1376+
assert.notOk(isSurface(otherEl), 'element: does not match outside the subtree');
1377+
1378+
paper.options.eventSurface = [rulerEl, otherEl];
1379+
assert.ok(isSurface(tickEl) && isSurface(otherEl), 'array: matches either subtree');
1380+
1381+
paper.options.eventSurface = (target) => target === otherEl;
1382+
assert.ok(isSurface(otherEl), 'function: matches what it accepts');
1383+
assert.notOk(isSurface(tickEl), 'function: rejects the rest');
1384+
1385+
// `target` is not always an element - `document` must not throw.
1386+
paper.options.eventSurface = '.ruler';
1387+
assert.notOk(isSurface(document), 'a non-element target is never a surface');
1388+
1389+
// `guard` is consulted before the surface test, so it can still veto single
1390+
// events within a surface - here the ruler stays hoverable but not pressable.
1391+
let blankPointerdownCount = 0;
1392+
let blankMouseoverCount = 0;
1393+
paper.on('blank:pointerdown', function() {
1394+
blankPointerdownCount += 1;
1395+
});
1396+
paper.on('blank:mouseover', function() {
1397+
blankMouseoverCount += 1;
1398+
});
1399+
paper.options.guard = function(evt) {
1400+
return evt.type === 'mousedown' && rulerEl.contains(evt.target);
1401+
};
1402+
1403+
simulate.mouseover({ el: tickEl, clientX: 10, clientY: 10 });
1404+
simulate.mousedown({ el: tickEl, clientX: 10, clientY: 10 });
1405+
simulate.mouseup({ el: tickEl, clientX: 10, clientY: 10 });
1406+
1407+
assert.equal(blankPointerdownCount, 0, 'guard vetoes the press inside the surface');
1408+
assert.equal(blankMouseoverCount, 1, 'the surface is still hoverable');
1409+
1410+
rulerEl.remove();
1411+
otherEl.remove();
1412+
});
1413+
12761414
QUnit.test('a press on a form control does not drag the element', function(assert) {
12771415

12781416
// FORM_CONTROL_TAG_NAMES marks a press on a <button>/<input>/<select>/<textarea>/

packages/joint-core/test/ts/index.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ const paper = new joint.dia.Paper({
160160

161161
paper.fitToContent({ padding: { top: 10 }, allowNewOrigin: false });
162162

163+
// `eventSurface` accepts a selector, an element, an array of elements or a predicate.
164+
const rulerEl = document.createElement('div');
165+
new joint.dia.Paper({ model: graph, eventSurface: '.ruler' });
166+
new joint.dia.Paper({ model: graph, eventSurface: rulerEl });
167+
new joint.dia.Paper({ model: graph, eventSurface: [rulerEl] });
168+
new joint.dia.Paper({ model: graph, eventSurface: (target) => target.classList.contains('ruler') });
169+
new joint.dia.Paper({ model: graph, eventSurface: null });
170+
// `guard` is called without a view when the event did not hit a cell view.
171+
new joint.dia.Paper({ model: graph, guard: (_evt, view) => view?.model.isElement() ?? false });
172+
163173
const cellView = graph.getCells()[0].findView(paper);
164174
cellView.vel.addClass('test-class');
165175

packages/joint-core/types/dia.d.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,7 +1760,10 @@ export namespace Paper {
17601760
linkPinning?: boolean;
17611761
allowLink?: ((linkView: LinkView, paper: Paper) => boolean) | null;
17621762
// events
1763-
guard?: (evt: Event, view: CellView) => boolean;
1763+
// `view` is undefined when the event did not hit a cell view (a blank area,
1764+
// or DOM content inside the paper element).
1765+
guard?: (evt: Event, view?: CellView) => boolean;
1766+
eventSurface?: string | DOMElement | DOMElement[] | ((target: DOMElement) => boolean) | null;
17641767
preventContextMenu?: boolean;
17651768
preventDefaultViewAction?: boolean;
17661769
preventDefaultBlankAction?: boolean;
@@ -2333,7 +2336,11 @@ export class Paper extends mvc.View<Graph> {
23332336

23342337
protected onlabel(evt: Event): void;
23352338

2336-
protected guard(evt: Event, view: CellView): boolean;
2339+
protected guard(evt: Event, view?: CellView): boolean;
2340+
2341+
protected guardExplicit(evt: Event, view?: CellView): boolean | undefined;
2342+
2343+
protected isEventSurface(target: EventTarget | null): boolean;
23372344

23382345
protected drawBackgroundImage(img: HTMLImageElement | null, opt?: { [key: string]: any }): void;
23392346

0 commit comments

Comments
 (0)