diff --git a/src/core/stores/index.ts b/src/core/stores/index.ts index 5d55749a4e..e9eb9b5be5 100644 --- a/src/core/stores/index.ts +++ b/src/core/stores/index.ts @@ -120,6 +120,37 @@ export const useCoreStore = defineStore('core', () => { */ zoom: mainStoreRefs.zoom, + /** + * Masks an interaction for a plugin. + * If the interaction is already masked by another plugin, an error is thrown. + * + * This may, for example, be used for interactions that should not be triggered while drawing. + * + * @param pluginId - ID of the plugin that wants to mask the interaction + * @param interaction - Name of the interaction to be masked + * @alpha + */ + maskInteraction: mainStore.maskInteraction, + + /** + * Unmasks an interaction for a plugin. + * If the interaction is not masked by the plugin, nothing happens. + * + * @param pluginId - ID of the plugin that wants to unmask the interaction + * @param interaction - Name of the interaction to be unmasked + * @alpha + */ + unmaskInteraction: mainStore.unmaskInteraction, + + /** + * Checks whether an interaction is masked by another plugin. + * + * @param interaction - Name of the interaction to be checked + * @returns `true` if the interaction is masked by another plugin, `false` otherwise + * @alpha + */ + isInteractionMasked: mainStore.isInteractionMasked, + /** * Before instantiating the map, all required plugins have to be added. Depending on how you use POLAR, this may * already have been done. Ready-made clients (that is, packages prefixed `@polar/client-`) come with plugins prepared. diff --git a/src/core/stores/main.ts b/src/core/stores/main.ts index fcecf83ac4..584122ff85 100644 --- a/src/core/stores/main.ts +++ b/src/core/stores/main.ts @@ -4,6 +4,7 @@ import type { ColorScheme, MapConfigurationIncludingDefaults, MasterportalApiServiceRegister, + PluginId, } from '../types' import { rawLayerList } from '@masterportal/masterportalapi' @@ -89,6 +90,31 @@ export const useMainStore = defineStore('main', () => { return { ...register, ...polar } as typeof polar } + const maskedInteractions = ref( + new globalThis.Map void }>() + ) + function maskInteraction( + pluginId: PluginId, + interaction: string, + setup: () => void, + teardown: () => void + ) { + if (maskedInteractions.value.has(interaction)) { + maskedInteractions.value.get(interaction)?.teardown() + } + maskedInteractions.value.set(interaction, { pluginId, teardown }) + setup() + } + function unmaskInteraction(pluginId: PluginId, interaction: string) { + if (maskedInteractions.value.get(interaction)?.pluginId === pluginId) { + maskedInteractions.value.get(interaction)?.teardown() + maskedInteractions.value.delete(interaction) + } + } + function isInteractionMasked(interaction: string) { + return maskedInteractions.value.has(interaction) + } + function setup() { addEventListener('resize', updateHasSmallDisplay) updateHasSmallDisplay() @@ -124,6 +150,9 @@ export const useMainStore = defineStore('main', () => { centerOnFeature, updateHasSmallDisplay, getLayerMapConfiguration, + maskInteraction, + unmaskInteraction, + isInteractionMasked, setup, teardown, } @@ -132,3 +161,71 @@ export const useMainStore = defineStore('main', () => { if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useMainStore, import.meta.hot)) } + +if (import.meta.vitest) { + const { vi, expect, test: _test } = import.meta.vitest + const { createPinia, setActivePinia } = await import('pinia') + + /* eslint-disable no-empty-pattern */ + const test = _test.extend<{ + store: ReturnType + }>({ + store: async ({}, use) => { + setActivePinia(createPinia()) + const store = useMainStore() + store.setup() + await use(store) + store.teardown() + }, + }) + /* eslint-enable no-empty-pattern */ + + test('interactions can be masked and unmasked', ({ store }) => { + const pluginId = 'external-test-plugin' + const interaction = 'click' + const setup = vi.fn() + const teardown = vi.fn() + + expect(store.isInteractionMasked(interaction)).toBe(false) + + store.maskInteraction(pluginId, interaction, setup, teardown) + expect(store.isInteractionMasked(interaction)).toBe(true) + expect(setup).toHaveBeenCalledTimes(1) + expect(teardown).toHaveBeenCalledTimes(0) + + store.unmaskInteraction(pluginId, interaction) + expect(store.isInteractionMasked(interaction)).toBe(false) + expect(setup).toHaveBeenCalledTimes(1) + expect(teardown).toHaveBeenCalledTimes(1) + }) + + test('masking the same interaction twice tears down the previous mask', ({ + store, + }) => { + const interaction = 'click' + const firstPluginId = 'external-test-plugin' + const firstSetup = vi.fn() + const firstTeardown = vi.fn() + const secondPluginId = 'external-second-test-plugin' + const secondSetup = vi.fn() + const secondTeardown = vi.fn() + + expect(store.isInteractionMasked(interaction)).toBe(false) + + store.maskInteraction(firstPluginId, interaction, firstSetup, firstTeardown) + expect(store.isInteractionMasked(interaction)).toBe(true) + expect(firstSetup).toHaveBeenCalledTimes(1) + expect(firstTeardown).toHaveBeenCalledTimes(0) + + store.maskInteraction( + secondPluginId, + interaction, + secondSetup, + secondTeardown + ) + expect(store.isInteractionMasked(interaction)).toBe(true) + expect(firstTeardown).toHaveBeenCalledTimes(1) + expect(secondSetup).toHaveBeenCalledTimes(1) + expect(secondTeardown).toHaveBeenCalledTimes(0) + }) +} diff --git a/src/plugins/pins/store.ts b/src/plugins/pins/store.ts index 7fdbbf4d6d..39e37a3d8a 100644 --- a/src/plugins/pins/store.ts +++ b/src/plugins/pins/store.ts @@ -13,7 +13,7 @@ import type { PinMovable, PinsPluginOptions } from './types' import { toMerged } from 'es-toolkit' import { pointerMove } from 'ol/events/condition' -import { Draw, Modify, Select, Translate } from 'ol/interaction' +import { Select, Translate } from 'ol/interaction' import { toLonLat } from 'ol/proj' import { defineStore } from 'pinia' import { computed, ref } from 'vue' @@ -167,28 +167,12 @@ export const usePinsStore = defineStore('plugins/pins', () => { } async function click(coordinate: Coordinate) { - const isDrawing = coreStore.map - .getInteractions() - .getArray() - .some( - (interaction) => - (interaction instanceof Draw && - // @ts-expect-error | internal hack to detect it from gfi plugin - (interaction._isMultiSelect || - // @ts-expect-error | internal hack to detect it from routing plugin - interaction._isRoutingDraw || - // @ts-expect-error | internal hack to detect it from draw plugin - interaction._isDrawPlugin)) || - interaction instanceof Modify || - // @ts-expect-error | internal hack to detect it from draw plugin - interaction._isDeleteSelect - ) const { minZoomLevel, movable } = configuration.value if ( (movable === 'drag' || movable === 'click') && // NOTE: It is assumed that getZoom actually returns the currentZoomLevel, thus the view has a constraint in the resolution. (coreStore.map.getView().getZoom() as number) >= minZoomLevel && - !isDrawing && + !coreStore.isInteractionMasked('click') && (await isCoordinateInBoundaryLayer( coordinate, coreStore.map, diff --git a/src/plugins/routing/store.ts b/src/plugins/routing/store.ts index e2fcd9fbc5..2257352dde 100644 --- a/src/plugins/routing/store.ts +++ b/src/plugins/routing/store.ts @@ -59,9 +59,18 @@ export const useRoutingStore = defineStore('plugins/routing', () => { _currentlyFocusedInput.value = index if (index !== -1) { - coreStore.map.addInteraction(draw as Draw) + coreStore.maskInteraction( + 'routing', + 'click', + () => { + coreStore.map.addInteraction(draw as Draw) + }, + () => { + coreStore.map.removeInteraction(draw as Draw) + } + ) } else { - coreStore.map.removeInteraction(draw as Draw) + coreStore.unmaskInteraction('routing', 'click') } }, }) @@ -221,12 +230,9 @@ export const useRoutingStore = defineStore('plugins/routing', () => { function initializeDraw() { draw = new Draw({ stopClick: true, type: 'Point' }) - // @ts-expect-error | internal hack to detect it in @polar/plugin-pins and @polar/plugin-gfi - draw._isRoutingDraw = true draw.on('drawend', (e) => { addCoordinateToRoute((e.feature.getGeometry() as Point).getCoordinates()) - // @ts-expect-error | internal hack to detect it in @polar/plugin-pins and @polar/plugin-gfi - draw._isRoutingDraw = false + coreStore.unmaskInteraction('routing', 'click') currentlyFocusedInput.value = -1 }) } @@ -293,7 +299,7 @@ export const useRoutingStore = defineStore('plugins/routing', () => { reset() if (draw) { - coreStore.map.removeInteraction(draw) + coreStore.unmaskInteraction('routing', 'click') draw = undefined } }