refactor: snapline and brush-select keep lineWidth after canvas zoom (#6198)

* refactor: sync camera operation to other layer cameras

* refactor(plugins): snapline keep lineWidth after zoom

* refactor(behaviors): brush-select keep lineWidth and exact match shortcut

* test: update test case

* test: update test case and snapshot

* fix: auto snap while zooming in

---------

Co-authored-by: antv <antv@antfin.com>
Co-authored-by: yvonneyx <zhuyuxin0627@gmail.com>
This commit is contained in:
Aaron 2024-08-20 17:24:15 +08:00 committed by GitHub
parent c9c1998fa1
commit a697f3a488
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 253 additions and 28 deletions

View File

@ -19,7 +19,7 @@ export const pluginSnapline: TestCase = async (context) => {
labelText: (datum) => datum.id,
},
},
behaviors: ['drag-element', 'drag-canvas'],
behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas'],
plugins: [
{
type: 'snapline',
@ -36,6 +36,7 @@ export const pluginSnapline: TestCase = async (context) => {
const config = {
filter: false,
offset: 20,
autoSnap: false,
};
pluginSnapline.form = (panel) => {
@ -48,7 +49,6 @@ export const pluginSnapline: TestCase = async (context) => {
key: 'snapline',
filter: (node: Node) => (filter ? node.id !== 'node3' : true),
});
graph.render();
}),
panel
.add(config, 'offset', [0, 20, Infinity])
@ -58,7 +58,15 @@ export const pluginSnapline: TestCase = async (context) => {
key: 'snapline',
offset,
});
graph.render();
}),
panel
.add(config, 'autoSnap')
.name('Auto Snap')
.onChange((autoSnap: boolean) => {
graph.updatePlugin({
key: 'snapline',
autoSnap,
});
}),
];
};

View File

@ -0,0 +1,93 @@
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" style="background: transparent; position: absolute; outline: none;" color-interpolation-filters="sRGB" tabindex="1">
<defs/>
<g transform="matrix(5,0,0,5,-1000,-1000)">
<g fill="none">
<g fill="none" class="elements">
<g fill="none" x="250.98000000000002" y="262.5" transform="matrix(1,0,0,1,250.979996,262.500000)">
<g>
<circle fill="rgba(153,173,209,1)" class="key" stroke-dasharray="0,0" stroke-width="1" fill-opacity="0.04" stroke="rgba(153,173,209,1)" r="155.87903130312299"/>
</g>
</g>
<g fill="none" marker-start="false" marker-end="false">
<g fill="none" marker-start="false" marker-end="false" stroke="transparent" stroke-width="3"/>
<g>
<path fill="none" d="M 164.31083505599867,242.84458247200067 L 235.68916494400133,207.15541752799933" class="key" stroke-width="1" stroke="rgba(153,173,209,1)"/>
<path fill="none" d="M 164.31083505599867,242.84458247200067 L 235.68916494400133,207.15541752799933" class="key" stroke-width="1" stroke="transparent"/>
</g>
</g>
<g fill="none" marker-start="false" marker-end="false">
<g fill="none" marker-start="false" marker-end="false" stroke="transparent" stroke-width="3"/>
<g>
<path fill="none" d="M 264.31083505599867,207.15541752799933 L 335.68916494400133,242.84458247200067" class="key" stroke-width="1" stroke="rgba(153,173,209,1)"/>
<path fill="none" d="M 264.31083505599867,207.15541752799933 L 335.68916494400133,242.84458247200067" class="key" stroke-width="1" stroke="transparent"/>
</g>
</g>
<g fill="none" marker-start="false" marker-end="false">
<g fill="none" marker-start="false" marker-end="false" stroke="transparent" stroke-width="3"/>
<g>
<path fill="none" d="M 335.68916494400133,257.1554175279993 L 264.31083505599867,292.8445824720007" class="key" stroke-width="1" stroke="rgba(153,173,209,1)"/>
<path fill="none" d="M 335.68916494400133,257.1554175279993 L 264.31083505599867,292.8445824720007" class="key" stroke-width="1" stroke="transparent"/>
</g>
</g>
<g fill="none" marker-start="false" marker-end="false">
<g fill="none" marker-start="false" marker-end="false" stroke="transparent" stroke-width="3"/>
<g>
<path fill="none" d="M 164.31083505599867,257.1554175279993 L 235.68916494400133,292.8445824720007" class="key" stroke-width="1" stroke="rgba(153,173,209,1)"/>
<path fill="none" d="M 164.31083505599867,257.1554175279993 L 235.68916494400133,292.8445824720007" class="key" stroke-width="1" stroke="transparent"/>
</g>
</g>
<g fill="none" x="150" y="250" transform="matrix(1,0,0,1,150,250)">
<g>
<circle fill="rgba(23,131,255,1)" class="key" stroke-width="0" stroke="rgba(0,0,0,1)" r="16"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,18)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node1
</text>
</g>
</g>
</g>
<g fill="none" x="250" y="200" transform="matrix(1,0,0,1,250,200)">
<g>
<circle fill="rgba(23,131,255,1)" class="key" stroke-width="0" stroke="rgba(0,0,0,1)" r="16"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,18)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node2
</text>
</g>
</g>
</g>
<g fill="none" x="350" y="250" transform="matrix(1,0,0,1,350,250)">
<g>
<circle fill="rgba(23,131,255,1)" class="key" stroke-width="0" stroke="rgba(0,0,0,1)" r="16"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,18)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node3
</text>
</g>
</g>
</g>
<g fill="none" x="250" y="300" transform="matrix(1,0,0,1,250,300)">
<g>
<circle fill="rgba(23,131,255,1)" class="key" stroke-width="0" stroke="rgba(0,0,0,1)" r="16"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,18)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node4
</text>
</g>
</g>
</g>
</g>
<g>
<path fill="rgba(0,128,0,1)" d="M 100,100 l 150,0 l 0,300 l-150 0 z" width="150" height="300" stroke-width="0.4" stroke="rgba(0,0,255,1)" fill-opacity="0.1" pointer-events="none" x="100" y="100"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" style="background: transparent; position: absolute; outline: none;" color-interpolation-filters="sRGB" tabindex="1">
<defs/>
<g transform="matrix(5,0,0,5,-1200,-1200)">
<g fill="none">
<g fill="none" class="elements">
<g fill="none" x="100" y="100" transform="matrix(1,0,0,1,100,100)">
<g>
<path fill="rgba(0,0,0,0)" d="M -30,-15 l 60,0 l 0,30 l-60 0 z" class="key" stroke-width="2" stroke="rgba(0,0,0,1)" width="60" height="30" x="-30" y="-15"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,17)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node1
</text>
</g>
</g>
</g>
<g fill="none" x="300" y="300" transform="matrix(1,0,0,1,300,300)">
<g>
<path fill="rgba(0,0,0,0)" d="M -30,-15 l 60,0 l 0,30 l-60 0 z" class="key" stroke-width="2" stroke="rgba(0,0,0,1)" width="60" height="30" x="-30" y="-15"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,17)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node2
</text>
</g>
</g>
</g>
<g fill="none" x="260" y="300" transform="matrix(1,0,0,1,260,300)">
<g>
<circle fill="rgba(0,0,0,0)" class="key" stroke-width="2" stroke="rgba(0,0,0,1)" r="20"/>
</g>
<g fill="none" class="label" transform="matrix(1,0,0,1,0,22)">
<g>
<text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0.5" dy="11.5px" class="text" font-size="12" font-family="system-ui, sans-serif" text-anchor="middle" fill-opacity="0.85" font-weight="400">
node3
</text>
</g>
</g>
</g>
</g>
<g>
<line fill="none" x1="0" y1="300" x2="500" y2="300" visibility="visible" stroke="rgba(23,199,111,1)" stroke-width="0.4"/>
</g>
<g>
<line fill="none" x1="100" y1="0" x2="100" y2="500" visibility="hidden" stroke="rgba(240,143,86,1)" stroke-width="2"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -128,6 +128,14 @@ describe('behavior brush select', () => {
graph.emit(CanvasEvent.CLICK);
await expect(graph).toMatchSnapshot(__filename, 'brush-clear-mode-intersect');
// zoom to test line width
graph.zoomTo(5);
graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' });
graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 250, y: 400 } });
await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-zoom');
graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 250, y: 400 } });
});
afterAll(() => {

View File

@ -1,4 +1,5 @@
import { Node, NodeEvent, type Graph } from '@/src';
import type { Graph, Node } from '@/src';
import { NodeEvent } from '@/src';
import { pluginSnapline } from '@@/demos';
import { createDemoGraph } from '../../utils';
@ -75,6 +76,16 @@ describe('plugin snapline', () => {
graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 });
await expect(graph).toMatchSnapshot(__filename, `auto-snap`);
graph.emit(NodeEvent.DRAG_END, { target: node });
// zoom to test lineWidth
graph.zoomTo(5, false, [300, 300]);
graph.updatePlugin({ key: 'snapline', autoSnap: false });
graph.updateNodeData([{ id: 'node3', style: { x: 260, y: 300 } }]);
graph.render();
graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' });
graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 });
await expect(graph).toMatchSnapshot(__filename, 'zoom-5');
graph.emit(NodeEvent.DRAG_END, { target: node });
});
afterAll(() => {

View File

@ -80,6 +80,24 @@ describe('shortcut', () => {
expect(drag).toHaveBeenCalledTimes(1);
expect(drag.mock.calls[0][0].deltaX).toBe(10);
expect(drag.mock.calls[0][0].deltaY).toBe(0);
drag.mockClear();
// shift drag
emitter.emit(CommonEvent.KEY_DOWN, { key: 'Shift' });
emitter.emit(CommonEvent.DRAG, { deltaX: 10, deltaY: 0 });
emitter.emit(CommonEvent.KEY_UP, { key: 'Shift' });
expect(drag).toHaveBeenCalledTimes(0);
shortcut.unbindAll();
shortcut.bind(['Shift', 'drag'], drag);
emitter.emit(CommonEvent.KEY_DOWN, { key: 'Shift' });
emitter.emit(CommonEvent.DRAG, { deltaX: 10, deltaY: 0 });
emitter.emit(CommonEvent.KEY_UP, { key: 'Shift' });
expect(drag).toHaveBeenCalledTimes(1);
expect(drag.mock.calls[0][0].deltaX).toBe(10);
expect(drag.mock.calls[0][0].deltaY).toBe(0);
});
it('focus', () => {

View File

@ -144,9 +144,16 @@ export class BrushSelect extends BaseBehavior<BrushSelectOptions> {
*/
protected onPointerDown(event: IPointerEvent) {
if (!this.validate(event) || !this.isKeydown() || this.startPoint) return;
const { canvas } = this.context;
const { canvas, graph } = this.context;
const style = { ...this.options.style };
this.rectShape = new Rect({ id: 'g6-brush-select', style: this.options.style });
// 根据缩放比例调整 lineWidth
// Adjust lineWidth according to the zoom ratio
if (this.options.style.lineWidth) {
style.lineWidth = +this.options.style.lineWidth / graph.getZoom();
}
this.rectShape = new Rect({ id: 'g6-brush-select', style });
canvas.appendChild(this.rectShape);
this.startPoint = [event.canvas.x, event.canvas.y];
@ -327,8 +334,7 @@ export class BrushSelect extends BaseBehavior<BrushSelectOptions> {
protected isKeydown(): boolean {
const { trigger } = this.options;
const keys = (Array.isArray(trigger) ? trigger : [trigger]) as string[];
if (!trigger || keys.includes('drag')) return true;
return this.shortcut!.match(keys);
return this.shortcut!.match(keys.filter((key) => key !== 'drag'));
}
/**
@ -348,7 +354,6 @@ export class BrushSelect extends BaseBehavior<BrushSelectOptions> {
private bindEvents() {
const { graph } = this.context;
this.unbindEvents();
graph.on(CommonEvent.POINTER_DOWN, this.onPointerDown);
graph.on(CommonEvent.POINTER_MOVE, this.onPointerMove);
@ -373,7 +378,9 @@ export class BrushSelect extends BaseBehavior<BrushSelectOptions> {
* @internal
*/
public update(options: Partial<BrushSelectOptions>) {
this.unbindEvents();
this.options = deepMix(this.options, options);
this.bindEvents();
}
/**

View File

@ -4,6 +4,7 @@ import { NodeEvent } from '../../constants';
import type { RuntimeContext } from '../../runtime/types';
import type { ID, IDragEvent, Node } from '../../types';
import { isVisible } from '../../utils/element';
import { divide } from '../../utils/vector';
import type { BasePluginOptions } from '../base-plugin';
import { BasePlugin } from '../base-plugin';
@ -133,27 +134,38 @@ export class Snapline extends BasePlugin<SnaplineOptions> {
this.verticalLine.style.visibility = 'hidden';
}
private getLineWidth(direction: 'horizontal' | 'vertical') {
const { lineWidth } = this.options[`${direction}LineStyle`] as LineStyleProps;
return +(lineWidth || defaultLineStyle.lineWidth || 1) / this.context.graph.getZoom();
}
private updateSnapline(metadata: Metadata) {
const { verticalX, verticalMinY, verticalMaxY, horizontalY, horizontalMinX, horizontalMaxX } = metadata;
const [canvasWidth, canvasHeight] = this.context.canvas.getSize();
const { offset } = this.options;
if (horizontalY !== null) {
this.horizontalLine.style.x1 = offset === Infinity ? 0 : horizontalMinX! - offset;
this.horizontalLine.style.y1 = horizontalY;
this.horizontalLine.style.x2 = offset === Infinity ? canvasWidth : horizontalMaxX! + offset;
this.horizontalLine.style.y2 = horizontalY;
this.horizontalLine.style.visibility = 'visible';
Object.assign(this.horizontalLine.style, {
x1: offset === Infinity ? 0 : horizontalMinX! - offset,
y1: horizontalY,
x2: offset === Infinity ? canvasWidth : horizontalMaxX! + offset,
y2: horizontalY,
visibility: 'visible',
lineWidth: this.getLineWidth('horizontal'),
});
} else {
this.horizontalLine.style.visibility = 'hidden';
}
if (verticalX !== null) {
this.verticalLine.style.x1 = verticalX;
this.verticalLine.style.y1 = offset === Infinity ? 0 : verticalMinY! - offset;
this.verticalLine.style.x2 = verticalX;
this.verticalLine.style.y2 = offset === Infinity ? canvasHeight : verticalMaxY! + offset;
this.verticalLine.style.visibility = 'visible';
Object.assign(this.verticalLine.style, {
x1: verticalX,
y1: offset === Infinity ? 0 : verticalMinY! - offset,
x2: verticalX,
y2: offset === Infinity ? canvasHeight : verticalMaxY! + offset,
visibility: 'visible',
lineWidth: this.getLineWidth('vertical'),
});
} else {
this.verticalLine.style.visibility = 'hidden';
}
@ -194,25 +206,37 @@ export class Snapline extends BasePlugin<SnaplineOptions> {
}
};
/**
* Get the delta of the drag
* @param event - drag event object
* @returns delta
* @internal
*/
protected getDelta(event: IDragEvent<Node>) {
const zoom = this.context.graph.getZoom();
return divide([event.dx, event.dy], zoom);
}
private enableSnap = (event: IDragEvent<Node>) => {
const { target } = event;
const threshold = 0.5;
if (this.isHorizontalSticking || this.isVerticalSticking) {
const [dx, dy] = this.getDelta(event);
if (
this.isHorizontalSticking &&
this.isVerticalSticking &&
Math.abs(event.dx) <= threshold &&
Math.abs(event.dy) <= threshold
Math.abs(dx) <= threshold &&
Math.abs(dy) <= threshold
) {
this.context.graph.translateElementBy({ [target.id]: [-event.dx, -event.dy] }, false);
this.context.graph.translateElementBy({ [target.id]: [-dx, -dy] }, false);
return false;
} else if (this.isHorizontalSticking && Math.abs(event.dy) <= threshold) {
this.context.graph.translateElementBy({ [target.id]: [0, -event.dy] }, false);
} else if (this.isHorizontalSticking && Math.abs(dy) <= threshold) {
this.context.graph.translateElementBy({ [target.id]: [0, -dy] }, false);
return false;
} else if (this.isVerticalSticking && Math.abs(event.dx) <= threshold) {
this.context.graph.translateElementBy({ [target.id]: [-event.dx, 0] }, false);
} else if (this.isVerticalSticking && Math.abs(dx) <= threshold) {
this.context.graph.translateElementBy({ [target.id]: [-dx, 0] }, false);
return false;
} else {
this.isHorizontalSticking = false;

View File

@ -34,12 +34,17 @@ export class ViewportController {
const { canvas } = this.context;
return new Proxy(canvas.getCamera(), {
get: (target, prop: keyof ICamera) => {
const transientCamera = canvas.getLayer('transient').getCamera();
const layers = Object.entries(canvas.getLayers()).filter(([name]) => !['main'].includes(name));
const cameras = layers.map(([, layer]) => layer.getCamera());
const value = target[prop];
if (typeof value === 'function') {
return (...args: any[]) => {
const result = (value as (...args: any[]) => any).apply(target, args);
(transientCamera[prop] as (...args: any[]) => any).apply(transientCamera, args);
cameras.forEach((camera) => {
(camera[prop] as (...args: any[]) => any).apply(camera, args);
});
return result;
};
}