From 4cb3b796fe0a25a6b7de7f65fb1cd9d8938a489a Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Wed, 1 Jul 2026 19:16:34 +0200 Subject: [PATCH 1/5] refactor: add getter for masked interactions to core --- src/core/stores/index.ts | 31 +++++++++++++++++ src/core/stores/main.ts | 70 +++++++++++++++++++++++++++++++++++++++ src/plugins/pins/store.ts | 20 ++--------- 3 files changed, 103 insertions(+), 18 deletions(-) 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..acbfed7dd1 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,26 @@ export const useMainStore = defineStore('main', () => { return { ...register, ...polar } as typeof polar } + const maskedInteractions = ref(new globalThis.Map()) + function maskInteraction(pluginId: PluginId, interaction: string) { + if (maskedInteractions.value.has(interaction)) { + throw new Error( + `Interaction "${interaction}" is already masked by plugin "${maskedInteractions.value.get( + interaction + )}"` + ) + } + maskedInteractions.value.set(interaction, pluginId) + } + function unmaskInteraction(pluginId: PluginId, interaction: string) { + if (maskedInteractions.value.get(interaction) === pluginId) { + maskedInteractions.value.delete(interaction) + } + } + function isInteractionMasked(interaction: string) { + return maskedInteractions.value.has(interaction) + } + function setup() { addEventListener('resize', updateHasSmallDisplay) updateHasSmallDisplay() @@ -124,6 +145,9 @@ export const useMainStore = defineStore('main', () => { centerOnFeature, updateHasSmallDisplay, getLayerMapConfiguration, + maskInteraction, + unmaskInteraction, + isInteractionMasked, setup, teardown, } @@ -132,3 +156,49 @@ export const useMainStore = defineStore('main', () => { if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useMainStore, import.meta.hot)) } + +if (import.meta.vitest) { + const { 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('Masking interactions works as expected', ({ store }) => { + const pluginId = 'external-test-plugin' + const interaction = 'click' + + expect(store.isInteractionMasked(interaction)).toBe(false) + + store.maskInteraction(pluginId, interaction) + expect(store.isInteractionMasked(interaction)).toBe(true) + + store.unmaskInteraction(pluginId, interaction) + expect(store.isInteractionMasked(interaction)).toBe(false) + }) + + test('Masking interactions twice fails', ({ store }) => { + const pluginId = 'external-test-plugin' + const interaction = 'click' + + expect(store.isInteractionMasked(interaction)).toBe(false) + + store.maskInteraction(pluginId, interaction) + expect(store.isInteractionMasked(interaction)).toBe(true) + + expect(() => { + store.maskInteraction(pluginId, interaction) + }).toThrow() + }) +} diff --git a/src/plugins/pins/store.ts b/src/plugins/pins/store.ts index b3dc024bdd..baf8f3363d 100644 --- a/src/plugins/pins/store.ts +++ b/src/plugins/pins/store.ts @@ -14,7 +14,7 @@ import { toMerged } from 'es-toolkit' import { pointerMove } from 'ol/events/condition' import Feature from 'ol/Feature' import Point from 'ol/geom/Point' -import { Draw, Modify, Select, Translate } from 'ol/interaction' +import { Select, Translate } from 'ol/interaction' import VectorLayer from 'ol/layer/Vector' import { toLonLat } from 'ol/proj' import { Vector } from 'ol/source' @@ -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, From 0254104e40cd9b1e600e80c4300cf4568ad0ce91 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Fri, 10 Jul 2026 18:44:56 +0200 Subject: [PATCH 2/5] fix(routing): use coreStore interaction masking --- src/plugins/routing/store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/routing/store.ts b/src/plugins/routing/store.ts index e2fcd9fbc5..b85b86d0c1 100644 --- a/src/plugins/routing/store.ts +++ b/src/plugins/routing/store.ts @@ -59,9 +59,11 @@ export const useRoutingStore = defineStore('plugins/routing', () => { _currentlyFocusedInput.value = index if (index !== -1) { + coreStore.maskInteraction('routing', 'click') coreStore.map.addInteraction(draw as Draw) } else { coreStore.map.removeInteraction(draw as Draw) + coreStore.unmaskInteraction('routing', 'click') } }, }) @@ -221,12 +223,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 }) } @@ -294,6 +293,7 @@ export const useRoutingStore = defineStore('plugins/routing', () => { if (draw) { coreStore.map.removeInteraction(draw) + coreStore.unmaskInteraction('routing', 'click') draw = undefined } } From 9f39e7ef16874bb7da8f76d92b302ef89b9a7ba1 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings <142312676+oeninghe-dataport@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:15:29 +0200 Subject: [PATCH 3/5] refactor(core): reword test description for maskInteraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pascal Röhling <73653210+dopenguin@users.noreply.github.com> --- src/core/stores/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/stores/main.ts b/src/core/stores/main.ts index acbfed7dd1..35a02ccfd3 100644 --- a/src/core/stores/main.ts +++ b/src/core/stores/main.ts @@ -175,7 +175,7 @@ if (import.meta.vitest) { }) /* eslint-enable no-empty-pattern */ - test('Masking interactions works as expected', ({ store }) => { + test('interactions can be masked and unmasked', ({ store }) => { const pluginId = 'external-test-plugin' const interaction = 'click' From 8e39cf6ece10099053d24213d246a15b007df402 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings <142312676+oeninghe-dataport@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:15:53 +0200 Subject: [PATCH 4/5] refactor(core): reword test in maskInteraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pascal Röhling <73653210+dopenguin@users.noreply.github.com> --- src/core/stores/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/stores/main.ts b/src/core/stores/main.ts index 35a02ccfd3..f2cdb62e55 100644 --- a/src/core/stores/main.ts +++ b/src/core/stores/main.ts @@ -188,7 +188,7 @@ if (import.meta.vitest) { expect(store.isInteractionMasked(interaction)).toBe(false) }) - test('Masking interactions twice fails', ({ store }) => { + test('masking the same interaction twice fails', ({ store }) => { const pluginId = 'external-test-plugin' const interaction = 'click' From 5fb2060b054a8e1865472fe8dd62c14857aada11 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Wed, 29 Jul 2026 16:23:40 +0200 Subject: [PATCH 5/5] feat: interaction masking is overridden by the later request --- src/core/stores/main.ts | 63 +++++++++++++++++++++++++----------- src/plugins/routing/store.ts | 14 +++++--- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/core/stores/main.ts b/src/core/stores/main.ts index f2cdb62e55..584122ff85 100644 --- a/src/core/stores/main.ts +++ b/src/core/stores/main.ts @@ -90,19 +90,24 @@ export const useMainStore = defineStore('main', () => { return { ...register, ...polar } as typeof polar } - const maskedInteractions = ref(new globalThis.Map()) - function maskInteraction(pluginId: PluginId, interaction: string) { + const maskedInteractions = ref( + new globalThis.Map void }>() + ) + function maskInteraction( + pluginId: PluginId, + interaction: string, + setup: () => void, + teardown: () => void + ) { if (maskedInteractions.value.has(interaction)) { - throw new Error( - `Interaction "${interaction}" is already masked by plugin "${maskedInteractions.value.get( - interaction - )}"` - ) + maskedInteractions.value.get(interaction)?.teardown() } - maskedInteractions.value.set(interaction, pluginId) + maskedInteractions.value.set(interaction, { pluginId, teardown }) + setup() } function unmaskInteraction(pluginId: PluginId, interaction: string) { - if (maskedInteractions.value.get(interaction) === pluginId) { + if (maskedInteractions.value.get(interaction)?.pluginId === pluginId) { + maskedInteractions.value.get(interaction)?.teardown() maskedInteractions.value.delete(interaction) } } @@ -158,7 +163,7 @@ if (import.meta.hot) { } if (import.meta.vitest) { - const { expect, test: _test } = import.meta.vitest + const { vi, expect, test: _test } = import.meta.vitest const { createPinia, setActivePinia } = await import('pinia') /* eslint-disable no-empty-pattern */ @@ -178,27 +183,49 @@ if (import.meta.vitest) { 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) + 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 fails', ({ store }) => { - const pluginId = 'external-test-plugin' + 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(pluginId, interaction) + store.maskInteraction(firstPluginId, interaction, firstSetup, firstTeardown) expect(store.isInteractionMasked(interaction)).toBe(true) - - expect(() => { - store.maskInteraction(pluginId, interaction) - }).toThrow() + 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/routing/store.ts b/src/plugins/routing/store.ts index b85b86d0c1..2257352dde 100644 --- a/src/plugins/routing/store.ts +++ b/src/plugins/routing/store.ts @@ -59,10 +59,17 @@ export const useRoutingStore = defineStore('plugins/routing', () => { _currentlyFocusedInput.value = index if (index !== -1) { - coreStore.maskInteraction('routing', 'click') - 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') } }, @@ -292,7 +299,6 @@ export const useRoutingStore = defineStore('plugins/routing', () => { reset() if (draw) { - coreStore.map.removeInteraction(draw) coreStore.unmaskInteraction('routing', 'click') draw = undefined }