Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/joint-core/src/dia/Paper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/joint-core/test/jointjs/paper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down
10 changes: 10 additions & 0 deletions packages/joint-core/test/ts/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
3 changes: 3 additions & 0 deletions packages/joint-core/types/dia.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2335,6 +2336,8 @@ export class Paper extends mvc.View<Graph> {

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;
Expand Down