diff --git a/package.json b/package.json index ef9cf02..7984ec9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "build": "rete build -c rete.config.ts", "doc": "rete doc", - "lint": "rete lint" + "lint": "rete lint", + "test": "rete test" }, "author": "Vitaliy Stoliarov", "license": "MIT", diff --git a/src/base.ts b/src/base.ts index e1c404a..44542ac 100644 --- a/src/base.ts +++ b/src/base.ts @@ -18,7 +18,7 @@ interface ConnectionView { interface Content { holder: HTMLElement add(element: HTMLElement): void - // eslint-disable-next-line no-undef + reorder(target: HTMLElement, next: ChildNode | null): void remove(element: HTMLElement): void } diff --git a/src/connection-view.ts b/src/connection-view.ts index 4a2db9d..99179f0 100644 --- a/src/connection-view.ts +++ b/src/connection-view.ts @@ -12,6 +12,8 @@ export class ConnectionView { this.element.style.position = 'absolute' this.element.style.left = '0' this.element.style.top = '0' - this.element.addEventListener('contextmenu', event => events.contextmenu(event)) + this.element.addEventListener('contextmenu', event => { + events.contextmenu(event) + }) } } diff --git a/src/content.ts b/src/content.ts index 41c743e..7bbe723 100644 --- a/src/content.ts +++ b/src/content.ts @@ -18,7 +18,6 @@ export class Content { this.holder.appendChild(element) } - // eslint-disable-next-line no-undef async reorder(target: HTMLElement, next: ChildNode | null) { if (!this.holder.contains(target)) { throw new Error(`content doesn't have 'target' for reordering`) diff --git a/src/extensions/restrictor.ts b/src/extensions/restrictor.ts index e9d44f9..4b3390f 100644 --- a/src/extensions/restrictor.ts +++ b/src/extensions/restrictor.ts @@ -50,7 +50,6 @@ export function restrictor(plugin: AreaPlugin(plugin: AreaPlugin, nodes: NodeRef[], params?: Params) { const { scale = 0.9 } = params || {} const editor = plugin.parentScope>(NodeEditor) diff --git a/src/index.ts b/src/index.ts index a930465..10d29cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -203,8 +203,12 @@ export class AreaPlugin exten */ public destroy() { this.container.removeEventListener('contextmenu', this.onContextMenu) - Array.from(this.connectionViews.keys()).forEach(id => this.removeConnectionView(id)) - Array.from(this.nodeViews.keys()).forEach(id => this.removeNodeView(id)) + Array.from(this.connectionViews.keys()).forEach(id => { + this.removeConnectionView(id) + }) + Array.from(this.nodeViews.keys()).forEach(id => { + this.removeNodeView(id) + }) this.area.destroy() } } diff --git a/src/node-view.ts b/src/node-view.ts index 3a81337..0a91f7c 100644 --- a/src/node-view.ts +++ b/src/node-view.ts @@ -27,7 +27,9 @@ export class NodeView { this.position = { x: 0, y: 0 } void this.translate(0, 0) - this.element.addEventListener('contextmenu', event => this.events.contextmenu(event)) + this.element.addEventListener('contextmenu', event => { + this.events.contextmenu(event) + }) this.dragHandler = new Drag() this.dragHandler.initialize( diff --git a/src/zoom-wheel.ts b/src/zoom-wheel.ts new file mode 100644 index 0000000..b4df998 --- /dev/null +++ b/src/zoom-wheel.ts @@ -0,0 +1,32 @@ +/** Pixels per wheel line ({@link WheelEvent.DOM_DELTA_LINE}) */ +export const LINE_HEIGHT_PX = 8 + +/** Pixels per wheel page ({@link WheelEvent.DOM_DELTA_PAGE}) */ +export const PAGE_HEIGHT_PX = 24 + +const DOM_DELTA_LINE = 1 +const DOM_DELTA_PAGE = 2 + +/** + * Converts wheel delta to pixels based on {@link WheelEvent.deltaMode}. + * @internal + */ +export function wheelDeltaToPixels(delta: number, mode: number): number { + if (mode === DOM_DELTA_LINE) return delta * LINE_HEIGHT_PX + if (mode === DOM_DELTA_PAGE) return delta * PAGE_HEIGHT_PX + + return delta +} + +/** + * Maps normalized wheel movement to a zoom delta, capped at `intensity`. + * @internal + */ +export function computeWheelZoomDelta(pixels: number, intensity: number): number { + if (pixels === 0) return 0 + + const unit = intensity / LINE_HEIGHT_PX + const raw = -pixels * unit + + return Math.sign(raw) * Math.min(intensity, Math.abs(raw)) +} diff --git a/src/zoom.ts b/src/zoom.ts index e37b31d..f4cb3f7 100644 --- a/src/zoom.ts +++ b/src/zoom.ts @@ -1,3 +1,4 @@ +import { computeWheelZoomDelta, wheelDeltaToPixels } from './zoom-wheel' /** * Zoom source @@ -35,8 +36,12 @@ export class Zoom { protected wheel = (e: WheelEvent) => { e.preventDefault() + const pixels = wheelDeltaToPixels(e.deltaY, e.deltaMode) + const delta = computeWheelZoomDelta(pixels, this.intensity) + + if (delta === 0) return + const { left, top } = this.element.getBoundingClientRect() - const delta = -this.intensity * Math.sign(e.deltaY); const ox = (left - e.clientX) * delta const oy = (top - e.clientY) * delta diff --git a/test/zoom-wheel.test.ts b/test/zoom-wheel.test.ts new file mode 100644 index 0000000..c92e76b --- /dev/null +++ b/test/zoom-wheel.test.ts @@ -0,0 +1,94 @@ +import { + computeWheelZoomDelta, + LINE_HEIGHT_PX, + PAGE_HEIGHT_PX, + wheelDeltaToPixels +} from '../src/zoom-wheel' + +const intensity = 0.1 + +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 +const DOM_DELTA_PAGE = 2 + +function signBasedWheelDelta(deltaY: number): number { + if (deltaY === 0) return 0 + + return -intensity * Math.sign(deltaY) +} + +function wheelZoomDelta(deltaY: number, deltaMode: number): number { + const pixels = wheelDeltaToPixels(deltaY, deltaMode) + + return computeWheelZoomDelta(pixels, intensity) +} + +describe('wheelDeltaToPixels', () => { + it('returns delta unchanged in DOM_DELTA_PIXEL mode', () => { + expect(wheelDeltaToPixels(120, DOM_DELTA_PIXEL)).toBe(120) + expect(wheelDeltaToPixels(0.05, DOM_DELTA_PIXEL)).toBe(0.05) + expect(wheelDeltaToPixels(-3, DOM_DELTA_PIXEL)).toBe(-3) + }) + + it('multiplies by line height in DOM_DELTA_LINE mode', () => { + expect(wheelDeltaToPixels(1, DOM_DELTA_LINE)).toBe(LINE_HEIGHT_PX) + expect(wheelDeltaToPixels(-2, DOM_DELTA_LINE)).toBe(-2 * LINE_HEIGHT_PX) + }) + + it('multiplies by page height in DOM_DELTA_PAGE mode', () => { + expect(wheelDeltaToPixels(1, DOM_DELTA_PAGE)).toBe(PAGE_HEIGHT_PX) + expect(wheelDeltaToPixels(-1, DOM_DELTA_PAGE)).toBe(-PAGE_HEIGHT_PX) + }) +}) + +describe('mouse-like wheel zoom', () => { + const cases = [ + { name: 'Chrome wheel down', deltaY: 120, deltaMode: DOM_DELTA_PIXEL, expected: -intensity }, + { name: 'Chrome wheel up', deltaY: -120, deltaMode: DOM_DELTA_PIXEL, expected: intensity }, + { name: 'Firefox wheel down', deltaY: 1, deltaMode: DOM_DELTA_LINE, expected: -intensity }, + { name: 'Firefox wheel up', deltaY: -1, deltaMode: DOM_DELTA_LINE, expected: intensity }, + { name: 'page scroll down', deltaY: 1, deltaMode: DOM_DELTA_PAGE, expected: -intensity } + ] + + cases.forEach(({ name, deltaY, deltaMode, expected }) => { + it(`${name} matches sign-based delta`, () => { + const delta = wheelZoomDelta(deltaY, deltaMode) + + expect(delta).toBe(expected) + expect(delta).toBe(signBasedWheelDelta(deltaY)) + }) + }) +}) + +describe('touchpad wheel zoom', () => { + const cases = [ + { name: 'pinch light', deltaY: 0.05, expected: -0.000625 }, + { name: 'pinch medium', deltaY: 0.5, expected: -0.00625 }, + { name: 'pinch strong', deltaY: 3, expected: -0.0375 } + ] + + cases.forEach(({ name, deltaY, expected }) => { + it(`${name} scales proportionally`, () => { + const delta = wheelZoomDelta(deltaY, DOM_DELTA_PIXEL) + + expect(delta).toBeCloseTo(expected) + expect(delta).not.toBe(signBasedWheelDelta(deltaY)) + }) + }) +}) + +describe('zero deltaY', () => { + it('ignores horizontal wheel and gesture end', () => { + expect(wheelZoomDelta(0, DOM_DELTA_PIXEL)).toBe(0) + expect(wheelZoomDelta(0, DOM_DELTA_PIXEL)).toBe(signBasedWheelDelta(0)) + }) +}) + +describe('small discrete wheel delta', () => { + it('uses proportional step below mouse cap threshold', () => { + const delta = wheelZoomDelta(4, DOM_DELTA_PIXEL) + + expect(delta).toBeCloseTo(-0.05) + expect(delta).not.toBe(signBasedWheelDelta(4)) + }) +}) diff --git a/test/zoom.test.ts b/test/zoom.test.ts new file mode 100644 index 0000000..6620aa2 --- /dev/null +++ b/test/zoom.test.ts @@ -0,0 +1,133 @@ +import { OnZoom, Zoom } from '../src/zoom' + +const intensity = 0.1 + +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 +const DOM_DELTA_PAGE = 2 + +class TestableZoom extends Zoom { + public setup(element: HTMLElement, onzoom: OnZoom) { + this.element = element + this.onzoom = onzoom + } + + public triggerWheel(event: Partial) { + this.wheel(event as WheelEvent) + } +} + +function createWheelEvent(overrides: Partial = {}): Partial { + return { + deltaY: 0, + deltaMode: 0, + clientX: 50, + clientY: 50, + preventDefault: jest.fn(), + ...overrides + } +} + +function createHolder(): HTMLElement { + return { + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}) + }) + } as unknown as HTMLElement +} + +function triggerWheel(deltaY: number, deltaMode: number) { + const onzoom = jest.fn() + const zoom = new TestableZoom(intensity) + + zoom.setup(createHolder(), onzoom) + zoom.triggerWheel(createWheelEvent({ deltaY, deltaMode })) + + return onzoom +} + +describe('Zoom wheel handler', () => { + describe('mouse-like wheel zoom', () => { + const cases = [ + { name: 'Chrome wheel down', deltaY: 120, deltaMode: DOM_DELTA_PIXEL, expected: -intensity }, + { name: 'Chrome wheel up', deltaY: -120, deltaMode: DOM_DELTA_PIXEL, expected: intensity }, + { name: 'Firefox wheel down', deltaY: 1, deltaMode: DOM_DELTA_LINE, expected: -intensity }, + { name: 'Firefox wheel up', deltaY: -1, deltaMode: DOM_DELTA_LINE, expected: intensity }, + { name: 'page scroll down', deltaY: 1, deltaMode: DOM_DELTA_PAGE, expected: -intensity } + ] + + cases.forEach(({ name, deltaY, deltaMode, expected }) => { + it(name, () => { + const onzoom = triggerWheel(deltaY, deltaMode) + + expect(onzoom).toHaveBeenCalledWith( + expected, + expect.any(Number), + expect.any(Number), + 'wheel' + ) + }) + }) + }) + + describe('touchpad wheel zoom', () => { + const cases = [ + { name: 'pinch light', deltaY: 0.05, expected: -0.000625 }, + { name: 'pinch medium', deltaY: 0.5, expected: -0.00625 }, + { name: 'pinch strong', deltaY: 3, expected: -0.0375 } + ] + + cases.forEach(({ name, deltaY, expected }) => { + it(name, () => { + const onzoom = triggerWheel(deltaY, DOM_DELTA_PIXEL) + + expect(onzoom).toHaveBeenCalledWith( + expect.closeTo(expected), + expect.any(Number), + expect.any(Number), + 'wheel' + ) + }) + }) + }) + + describe('zero deltaY', () => { + it('ignores horizontal wheel and gesture end', () => { + const onzoom = triggerWheel(0, DOM_DELTA_PIXEL) + + expect(onzoom).not.toHaveBeenCalled() + }) + }) + + describe('small discrete wheel delta', () => { + it('uses proportional step below mouse cap threshold', () => { + const onzoom = triggerWheel(4, DOM_DELTA_PIXEL) + + expect(onzoom).toHaveBeenCalledWith( + expect.closeTo(-0.05), + expect.any(Number), + expect.any(Number), + 'wheel' + ) + }) + }) + + it('calls preventDefault on wheel', () => { + const onzoom = jest.fn() + const zoom = new TestableZoom(intensity) + const event = createWheelEvent({ deltaY: 120 }) + + zoom.setup(createHolder(), onzoom) + zoom.triggerWheel(event) + + expect(event.preventDefault).toHaveBeenCalled() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 0edbe42..309b784 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,5 @@ "DOM" ] }, - "include": ["src"] + "include": ["src", "test"] }