diff --git a/src/display/update.js b/src/display/update.js index 50d6f332..72b3cbd6 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -7,6 +7,7 @@ import { validate } from '../utils/validator'; const updateSchema = z.object({ path: z.nullable(z.string()).default(null), + elements: z.unknown().optional(), changes: z.record(z.unknown()).nullable().default(null), history: z.union([z.boolean(), z.string()]).default(false), relativeTransform: z.boolean().default(false), diff --git a/src/utils/event/canvas.js b/src/utils/event/canvas.js index 9e516a45..5904d2e8 100644 --- a/src/utils/event/canvas.js +++ b/src/utils/event/canvas.js @@ -1,5 +1,6 @@ import { z } from 'zod'; import { isValidationError } from 'zod-validation-error'; +import { convertArray } from '../convert'; import { selector } from '../selector/selector'; import { uid } from '../uuid'; import { validate } from '../validator'; @@ -7,6 +8,7 @@ import { validate } from '../validator'; const addEventSchema = z.object({ id: z.string().default(''), path: z.string().default('$'), + elements: z.unknown().optional(), action: z.string(), fn: z.function(), options: z.unknown(), @@ -16,11 +18,24 @@ export const addEvent = (viewport, opts) => { const config = validate(opts, addEventSchema); if (isValidationError(config)) throw config; - const { path, action, fn, options } = config; + const { action, fn, options } = config; const id = config.id || uid(); + const hasElements = opts && Object.hasOwn(opts, 'elements'); + const hasPath = opts && Object.hasOwn(opts, 'path'); + const elements = hasElements + ? convertArray(config.elements).filter(Boolean) + : null; + const path = hasPath || !hasElements ? config.path : null; if (!(id in viewport.events)) { - viewport.events[id] = { path, action, fn, options, active: false }; + viewport.events[id] = { + path, + elements, + action, + fn, + options, + active: false, + }; } else { logEventExists(id); } @@ -55,7 +70,7 @@ export const onEvent = (viewport, id) => { if (event.active) continue; const actions = splitByWhitespace(event.action); - const objects = selector(viewport, event.path); + const objects = getEventObjects(viewport, event); for (const object of objects) { addAction(object, actions, event); } @@ -82,7 +97,7 @@ export const offEvent = (viewport, id) => { if (!event.active) continue; const actions = splitByWhitespace(event.action); - const objects = selector(viewport, event.path); + const objects = getEventObjects(viewport, event); for (const object of objects) { removeAction(object, actions, event); } @@ -120,4 +135,9 @@ const logNoEventExists = (eventId) => { console.warn(`No event exists for the eventId: ${eventId}.`); }; +const getEventObjects = (viewport, event) => [ + ...(event.elements ?? []), + ...(event.path ? selector(viewport, event.path) : []), +]; + const splitByWhitespace = (str) => str.split(/\s+/).filter(Boolean); diff --git a/src/utils/event/canvas.test.js b/src/utils/event/canvas.test.js new file mode 100644 index 00000000..c36c8b85 --- /dev/null +++ b/src/utils/event/canvas.test.js @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../selector/selector', () => { + return { selector: vi.fn() }; +}); + +import { selector } from '../selector/selector'; +import { + addEvent, + offEvent, + onEvent, + removeAllEvent, + removeEvent, +} from './canvas'; + +const createViewport = () => ({ events: {} }); + +const createListenerObject = () => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); + +beforeEach(() => { + selector.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('canvas event utilities', () => { + it('stores events with default path and inactive state', () => { + const viewport = createViewport(); + const handler = vi.fn(); + + const id = addEvent(viewport, { + id: 'evt', + action: 'click', + fn: handler, + options: { passive: true }, + }); + + expect(id).toBe('evt'); + expect(viewport.events.evt).toMatchObject({ + path: '$', + elements: null, + action: 'click', + fn: expect.any(Function), + options: { passive: true }, + active: false, + }); + + viewport.events.evt.fn(); + expect(handler).toHaveBeenCalled(); + }); + + it('stores elements without path when only elements are provided', () => { + const viewport = createViewport(); + const object = createListenerObject(); + + addEvent(viewport, { + id: 'evt', + elements: object, + action: 'click', + fn: vi.fn(), + options: null, + }); + + expect(viewport.events.evt.path).toBeNull(); + expect(viewport.events.evt.elements).toEqual([object]); + }); + + it('attaches listeners to elements and selector results', () => { + const viewport = createViewport(); + const elementObject = createListenerObject(); + const selectorObject = createListenerObject(); + + selector.mockReturnValue([selectorObject]); + + addEvent(viewport, { + id: 'evt', + path: '$.children[*]', + elements: [elementObject], + action: 'click hover', + fn: vi.fn(), + options: { passive: true }, + }); + + onEvent(viewport, 'evt'); + + expect(selector).toHaveBeenCalledWith(viewport, '$.children[*]'); + expect(elementObject.addEventListener).toHaveBeenCalledTimes(2); + expect(selectorObject.addEventListener).toHaveBeenCalledTimes(2); + expect(viewport.events.evt.active).toBe(true); + }); + + it('skips selector when only elements are provided', () => { + const viewport = createViewport(); + const elementObject = createListenerObject(); + + addEvent(viewport, { + id: 'evt', + elements: [elementObject], + action: 'click', + fn: vi.fn(), + options: null, + }); + + onEvent(viewport, 'evt'); + + expect(selector).not.toHaveBeenCalled(); + expect(elementObject.addEventListener).toHaveBeenCalledTimes(1); + }); + + it('removes listeners when deactivating events', () => { + const viewport = createViewport(); + const elementObject = createListenerObject(); + + addEvent(viewport, { + id: 'evt', + elements: [elementObject], + action: 'click hover', + fn: vi.fn(), + options: null, + }); + + onEvent(viewport, 'evt'); + offEvent(viewport, 'evt'); + offEvent(viewport, 'evt'); + + expect(elementObject.removeEventListener).toHaveBeenCalledTimes(2); + expect(viewport.events.evt.active).toBe(false); + }); + + it('removes events and detaches listeners', () => { + const viewport = createViewport(); + const elementObject = createListenerObject(); + + addEvent(viewport, { + id: 'evt', + elements: [elementObject], + action: 'click', + fn: vi.fn(), + options: null, + }); + + onEvent(viewport, 'evt'); + removeEvent(viewport, 'evt'); + + expect(viewport.events.evt).toBeUndefined(); + expect(elementObject.removeEventListener).toHaveBeenCalledTimes(1); + }); + + it('removes all events', () => { + const viewport = createViewport(); + const objectA = createListenerObject(); + const objectB = createListenerObject(); + + addEvent(viewport, { + id: 'evt-a', + elements: [objectA], + action: 'click', + fn: vi.fn(), + options: null, + }); + addEvent(viewport, { + id: 'evt-b', + elements: [objectB], + action: 'hover', + fn: vi.fn(), + options: null, + }); + + onEvent(viewport, 'evt-a evt-b'); + removeAllEvent(viewport); + + expect(Object.keys(viewport.events)).toHaveLength(0); + expect(objectA.removeEventListener).toHaveBeenCalledTimes(1); + expect(objectB.removeEventListener).toHaveBeenCalledTimes(1); + }); + + it('warns when activating a missing event', () => { + const viewport = createViewport(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + onEvent(viewport, 'missing'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +});