feat(plugins): let plugins open built-in control panels (#3190)

* feat(plugins): expose built-in control panels to plugins

Share panel toggles with the scripting API so plugins can open the existing host controls. Allow Minimap zoom and size options while keeping menu visibility synchronized.

* Address Claude review feedback

Preserve Minimap visibility and collapsed state when updating its options. Keep repeated direct opens idempotent and retain collapsed state on basemap refreshes.

* Address CodeRabbit review feedback

Invalidate pending bookmark, search, measure, and print opens when closed or torn down. Forward scripting closes during initialization and cover both constructor loading and deferred show callbacks in regression tests.
This commit is contained in:
Qiusheng Wu authored and GitHub committed 2026-10-24 00:06:43 -04:00
1 parent 32f9e373e8
commit a3002fe2c0
15 files changed
+596 -80

No files matched your search

@@ -4,6 +4,9 @@ import { reserveBuiltInPluginIds } from "../lib/plugin-registry";
import { ensurePluginBlocklistLoaded, hasPluginBlocklistEntries } from "../lib/plugin-blocklist";
import {
addRasterToMap,
openControlPanel,
closeControlPanel,
isControlPanelVisible,
readRasterWindow,
setRasterRenderEngine,
addZarrRasterLayer,
@@ -1195,6 +1198,9 @@ export function useTimeSliderAutoClose(mapControllerRef: RefObject<MapEngine | n
* engine hooks.
*/
const appApiHost: AppApiHost = {
openControlPanel,
closeControlPanel,
isControlPanelVisible,
plugins: manager,
projectPluginStateSnapshot,
persistProjectPluginState,
+9
View File
@@ -27,6 +27,8 @@ import type {
GeoLibreActiveMapTool,
GeoLibreCogLayerOptions,
GeoLibreCogRenderEngine,
GeoLibreControlPanelId,
GeoLibreMinimapOptions,
GeoLibreDeckGL,
GeoLibreDownloadFolder,
GeoLibreEditSessionChange,
@@ -109,6 +111,9 @@ type PluginsModule = typeof GeoLibrePlugins;
export interface AppApiHost extends Pick<
PluginsModule,
| "addRasterToMap"
| "openControlPanel"
| "closeControlPanel"
| "isControlPanelVisible"
| "readRasterWindow"
| "setRasterRenderEngine"
| "addZarrRasterLayer"
@@ -818,6 +823,10 @@ export function createAppAPI(
state.removeLayer(id);
}
},
openControlPanel: (id: GeoLibreControlPanelId, options?: GeoLibreMinimapOptions): void =>
host.openControlPanel(api, id, options),
closeControlPanel: (id: GeoLibreControlPanelId): void => host.closeControlPanel(api, id),
isControlPanelVisible: host.isControlPanelVisible,
addMapControl: (
control: Parameters<MapEngine["addControl"]>[0],
position?: Parameters<MapEngine["addControl"]>[1],
@@ -15,26 +15,7 @@ import {
type WhiteboxLayerInput,
type WhiteboxTool,
} from "@geolibre/processing";
import {
SKETCHES_SOURCE_KIND,
addRasterToMap,
closeBookmarkPanel,
closeMeasurePanel,
closeMinimapPanel,
closePrintPanel,
closeSearchPlacesPanel,
isBookmarkPanelVisible,
isMeasurePanelVisible,
isMinimapPanelVisible,
isPrintPanelVisible,
isSearchPlacesPanelVisible,
openBookmarkPanel,
openMeasurePanel,
openMinimapPanel,
openPrintPanel,
openSearchPlacesPanel,
type GeoLibreAppAPI,
} from "@geolibre/plugins";
import { SKETCHES_SOURCE_KIND, addRasterToMap } from "@geolibre/plugins";
import type { Feature, FeatureCollection } from "geojson";
import type { RefObject } from "react";
import type { MapEngine } from "@geolibre/map";
@@ -56,7 +37,6 @@ import {
recordScriptMapControl,
setScriptIdentify,
type ScriptMapControlDetail,
type ScriptablePanel,
} from "./ui-controls";
// The scripting command surface, shared by every programmatic entry point: the
@@ -116,30 +96,6 @@ function addWhiteboxRasterOutput(
return addRasterToMap(createAppAPI(controllerRefFrom(getController)), file, { name });
}
/** Open/close/read handlers for each scriptable toolbar panel. */
const PANEL_TOGGLES: Record<
ScriptablePanel,
{
isVisible: () => boolean;
open: (app: GeoLibreAppAPI) => void;
close: (app: GeoLibreAppAPI) => void;
}
> = {
bookmark: {
isVisible: isBookmarkPanelVisible,
open: openBookmarkPanel,
close: closeBookmarkPanel,
},
search: {
isVisible: isSearchPlacesPanelVisible,
open: openSearchPlacesPanel,
close: () => closeSearchPlacesPanel(),
},
measure: { isVisible: isMeasurePanelVisible, open: openMeasurePanel, close: closeMeasurePanel },
minimap: { isVisible: isMinimapPanelVisible, open: openMinimapPanel, close: closeMinimapPanel },
print: { isVisible: isPrintPanelVisible, open: openPrintPanel, close: closePrintPanel },
};
function whiteboxToolName(tool: WhiteboxTool): string {
return tool.display_name || tool.id.replace(/_/g, " ");
}
@@ -313,13 +269,11 @@ export function createScriptingHandlers(deps: ScriptingDeps): ScriptingHandlers
const control = params.control;
const visible = Boolean(params.visible);
if (isScriptablePanel(control)) {
const panel = PANEL_TOGGLES[control];
// Opening an open panel would remount it; closing a closed one is a
// no-op either way, but skip it so the two branches read the same.
if (panel.isVisible() === visible) return visible;
// The shared helpers make opens idempotent. Always forward a close
// so it can cancel initialization before a panel becomes visible.
const app = createAppAPI(controllerRefFrom(getController));
if (visible) panel.open(app);
else panel.close(app);
if (visible) app.openControlPanel(control);
else app.closeControlPanel(control);
return visible;
}
if (isScriptableMapControl(control)) {
@@ -1,4 +1,5 @@
import { IDENTIFY_ALL_LAYERS_ID, resolveIdentifyTarget, useAppStore } from "@geolibre/core";
import type { GeoLibreControlPanelId } from "@geolibre/plugins";
import type { BuiltInMapControl } from "@geolibre/map";
// Scripted control of UI state that is not part of the project: which layer
@@ -30,7 +31,13 @@ export const SCRIPTABLE_MAP_CONTROLS = [
export type ScriptableMapControl = (typeof SCRIPTABLE_MAP_CONTROLS)[number];
/** Toolbar panels (Controls menu) a script may open or close. */
export const SCRIPTABLE_PANELS = ["bookmark", "search", "measure", "minimap", "print"] as const;
export const SCRIPTABLE_PANELS = [
"bookmark",
"search",
"measure",
"minimap",
"print",
] as const satisfies readonly GeoLibreControlPanelId[];
export type ScriptablePanel = (typeof SCRIPTABLE_PANELS)[number];
+36
View File
@@ -296,6 +296,13 @@ export interface GeoLibreAppAPI {
position?: GeoLibreMapControlPosition
) => boolean;
removeMapControl: (control: IControl) => void;
// The host's existing menu panels. See "Built-in control panels" below.
openControlPanel?: {
(id: "minimap", options: GeoLibreMinimapOptions): void;
(id: GeoLibreControlPanelId): void;
};
closeControlPanel?: (id: GeoLibreControlPanelId) => void;
isControlPanelVisible?: (id: GeoLibreControlPanelId) => boolean;
// Note: showing the "terrain" control (visible: true) also switches 3D
// terrain on, mirroring the Controls menu so the user doesn't have to click
// the control button as a second step. Hiding it turns terrain back off.
@@ -1304,6 +1311,35 @@ app.registerExternalNativeLayer?.({
For Zarr specifically, prefer `addZarrLayer` above: the host's renderer already integrates with the panels, so no custom layer or bridge is needed.
## Built-in control panels
Plugins can open GeoLibre's existing `bookmark`, `search`, `measure`, `minimap`,
and `print` panels through the host API. The Controls menu checkmarks follow
the shared visibility state for bookmark, search, measure, and minimap, so a
plugin does not need to create a second control. `print` opens the scripting
API's Print control, which is separate from Project > Print Layout. Opening an
open panel is a no-op; controls initialize asynchronously.
These methods are optional on older hosts, so use optional chaining:
```ts
activate(app) {
app.openControlPanel?.("minimap", { zoomOffset: -6, width: 320, height: 220 });
}
// Later, read or close the same panel the menus control.
const visible = app.isControlPanelVisible?.("minimap");
app.closeControlPanel?.("minimap");
app.openControlPanel?.("bookmark");
```
Only `minimap` accepts options. `zoomOffset` is relative to the main map: a more
negative value gives a wider overview. It defaults to `-4`; `width` and `height`
default to `250` and `180` pixels and must be positive finite numbers. The zoom
offset must also be finite. Calling `openControlPanel("minimap", options)` again
updates the existing overview, retaining omitted options. The options survive
basemap changes and reset when the Minimap is closed. Panel visibility and
these options are session UI state and are not saved with the project.
## Right sidebar panels
A plugin can register a native right-sidebar panel that docks beside the built-in Style panel and behaves like a first-class part of the workspace, instead of emulating one with a fixed overlay. The host renders the panel chrome (a header with collapse and close buttons, a collapsible rail, and a resize handle); the plugin owns only the body.
+76
View File
@@ -0,0 +1,76 @@
import type { GeoLibreAppAPI, GeoLibreControlPanelId, GeoLibreMinimapOptions } from "./types";
import {
closeBookmarkPanel,
isBookmarkPanelVisible,
openBookmarkPanel,
} from "./plugins/components/bookmark";
import {
closeMeasurePanel,
isMeasurePanelVisible,
openMeasurePanel,
} from "./plugins/components/measure";
import {
closeMinimapPanel,
isMinimapPanelVisible,
openMinimapPanel,
} from "./plugins/components/minimap";
import { closePrintPanel, isPrintPanelVisible, openPrintPanel } from "./plugins/components/print";
import {
closeSearchPlacesPanel,
isSearchPlacesPanelVisible,
openSearchPlacesPanel,
} from "./plugins/components/search";
/** Shared by the plugin host and scripting API so they address the same controls. */
const PANEL_TOGGLES: Record<
GeoLibreControlPanelId,
{
isVisible: () => boolean;
open: (app: GeoLibreAppAPI) => void;
close: (app: GeoLibreAppAPI) => void;
}
> = {
bookmark: {
isVisible: isBookmarkPanelVisible,
open: openBookmarkPanel,
close: closeBookmarkPanel,
},
search: {
isVisible: isSearchPlacesPanelVisible,
open: openSearchPlacesPanel,
close: closeSearchPlacesPanel,
},
measure: { isVisible: isMeasurePanelVisible, open: openMeasurePanel, close: closeMeasurePanel },
minimap: { isVisible: isMinimapPanelVisible, open: openMinimapPanel, close: closeMinimapPanel },
print: { isVisible: isPrintPanelVisible, open: openPrintPanel, close: closePrintPanel },
};
function panelToggle(id: GeoLibreControlPanelId) {
if (!Object.hasOwn(PANEL_TOGGLES, id)) throw new Error(`Unknown control panel: ${id}`);
return PANEL_TOGGLES[id];
}
/** Open a built-in panel; supplied Minimap options also update an open overview. */
export function openControlPanel(
app: GeoLibreAppAPI,
id: GeoLibreControlPanelId,
options?: GeoLibreMinimapOptions,
): void {
const panel = panelToggle(id);
if (options !== undefined) {
if (id !== "minimap") throw new Error("Control panel options are only supported for minimap");
openMinimapPanel(app, options);
} else if (!panel.isVisible()) {
panel.open(app);
}
}
/** Close a built-in panel and cancel its pending initialization. */
export function closeControlPanel(app: GeoLibreAppAPI, id: GeoLibreControlPanelId): void {
panelToggle(id).close(app);
}
/** Read the same visibility state the menus subscribe to. */
export function isControlPanelVisible(id: GeoLibreControlPanelId): boolean {
return panelToggle(id).isVisible();
}
+1
View File
@@ -12,6 +12,7 @@ export {
type AssistantToolEntry,
} from "./assistant-tool-registry";
export * from "./types";
export { openControlPanel, closeControlPanel, isControlPanelVisible } from "./control-panels";
export { PluginManager } from "./plugin-manager";
export {
registerRightPanel,
@@ -154,6 +154,7 @@ let syncedGroups: ProjectBookmarkGroup[] | null = null;
let unsubscribeStore: (() => void) | null = null;
let bookmarkControlMounted = false;
let bookmarkPanelVisible = false;
let bookmarkRequest = 0;
const bookmarkPanelListeners = new Set<() => void>();
// Standalone Bookmark panel, opened on demand from the Controls menu.
@@ -174,8 +175,12 @@ export function subscribeBookmarkPanel(listener: () => void): () => void {
return () => bookmarkPanelListeners.delete(listener);
}
async function openStandaloneBookmarkControl(app: GeoLibreAppAPI): Promise<boolean> {
async function openStandaloneBookmarkControl(
app: GeoLibreAppAPI,
request = ++bookmarkRequest,
): Promise<boolean> {
const { BookmarkControl: BookmarkControlClass } = await getComponentsConstructors();
if (request !== bookmarkRequest) return false;
migrateLegacyBookmarks();
bookmarkControl ??= createBookmarkControl(BookmarkControlClass, app);
@@ -189,10 +194,11 @@ async function openStandaloneBookmarkControl(app: GeoLibreAppAPI): Promise<boole
bookmarkControlMounted = true;
}
const control = bookmarkControl;
setTimeout(() => {
if (!bookmarkControl) return;
bookmarkControl.show();
bookmarkControl.expand();
if (request !== bookmarkRequest || bookmarkControl !== control) return;
control.show();
control.expand();
setBookmarkPanelVisible(true);
}, 0);
return true;
@@ -417,6 +423,7 @@ function routeBookmarkFileIoThroughHost(control: BookmarkControl, app: GeoLibreA
}
export function teardownBookmarkControl(app: GeoLibreAppAPI): void {
++bookmarkRequest;
if (bookmarkControl && bookmarkControlMounted) {
app.removeMapControl(bookmarkControl);
}
@@ -44,6 +44,7 @@ let measureRadiusUnsubscribe: (() => void) | null = null;
let measureEllipsoidId: string | null = null;
let measureControlMounted = false;
let measurePanelVisible = false;
let measureRequest = 0;
const measurePanelListeners = new Set<() => void>();
setMeasureControlReader(() => measureControl);
@@ -65,8 +66,12 @@ export function subscribeMeasurePanel(listener: () => void): () => void {
return () => measurePanelListeners.delete(listener);
}
async function openStandaloneMeasureControl(app: GeoLibreAppAPI): Promise<boolean> {
async function openStandaloneMeasureControl(
app: GeoLibreAppAPI,
request = ++measureRequest,
): Promise<boolean> {
const { MeasureControl: MeasureControlClass } = await getComponentsConstructors();
if (request !== measureRequest) return false;
measureControl ??= createMeasureControl(MeasureControlClass);
@@ -91,13 +96,14 @@ async function openStandaloneMeasureControl(app: GeoLibreAppAPI): Promise<boolea
}
refreshLidarMeasureMirror(app);
const control = measureControl;
setTimeout(() => {
// Guard against a teardown that nulled measureControl between addMapControl
// succeeding and this deferred callback firing, which would otherwise mark
// the panel visible even though the control no longer exists.
if (!measureControl) return;
measureControl.show();
measureControl.expand();
if (request !== measureRequest || measureControl !== control) return;
control.show();
control.expand();
setMeasurePanelVisible(true);
}, 0);
return true;
@@ -153,6 +159,7 @@ function makeMeasurePanelResizable(control: MeasureControl): void {
}
export function teardownMeasureControl(app: GeoLibreAppAPI): void {
++measureRequest;
measureTerrainDetach?.();
measureTerrainDetach = null;
measureRadiusUnsubscribe?.();
@@ -2,10 +2,15 @@
// Split out of maplibre-components.ts (opengeos/GeoLibre#2633).
import type { MinimapControl, MinimapControlOptions } from "maplibre-gl-components";
import type { GeoLibreAppAPI, GeoLibreMapControlPosition } from "../../types";
import type {
GeoLibreAppAPI,
GeoLibreMapControlPosition,
GeoLibreMinimapOptions,
} from "../../types";
import { getComponentsConstructors, type MinimapControlConstructor } from "./constructors";
const minimapControlPosition: GeoLibreMapControlPosition = "bottom-left";
const minimapOptionKeys = ["zoomOffset", "width", "height"] as const;
const MINIMAP_OPTIONS = {
className: "geolibre-minimap-control",
@@ -21,11 +26,31 @@ let minimapControl: MinimapControl | null = null;
let minimapControlMounted = false;
let minimapBasemapUnsubscribe: (() => void) | null = null;
let minimapPanelVisible = false;
let minimapRequest = 0;
let minimapOptions = defaultMinimapOptions();
const minimapPanelListeners = new Set<() => void>();
// Standalone Minimap control, toggled from the Controls menu.
export function openMinimapPanel(app: GeoLibreAppAPI): void {
void openStandaloneMinimapControl(app);
export function openMinimapPanel(app: GeoLibreAppAPI, options?: GeoLibreMinimapOptions): void {
const next = { ...minimapOptions };
for (const key of minimapOptionKeys) {
const value = options?.[key];
if (value === undefined) continue;
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
(key !== "zoomOffset" && value <= 0)
) {
throw new Error(
`Minimap ${key} must be a finite ${key === "zoomOffset" ? "number" : "positive number"}`,
);
}
next[key] = value;
}
const optionsChanged = minimapOptionKeys.some((key) => next[key] !== minimapOptions[key]);
if (!optionsChanged && (minimapPanelVisible || minimapControlMounted)) return;
minimapOptions = next;
void openStandaloneMinimapControl(app, ++minimapRequest, optionsChanged);
}
export function closeMinimapPanel(app: GeoLibreAppAPI): void {
@@ -41,15 +66,32 @@ export function subscribeMinimapPanel(listener: () => void): () => void {
return () => minimapPanelListeners.delete(listener);
}
async function openStandaloneMinimapControl(app: GeoLibreAppAPI): Promise<boolean> {
async function openStandaloneMinimapControl(
app: GeoLibreAppAPI,
request: number,
optionsChanged: boolean,
): Promise<boolean> {
const { MinimapControl: MinimapControlClass } = await getComponentsConstructors();
if (request !== minimapRequest) return false;
minimapControl ??= createMinimapControl(MinimapControlClass, app.getActiveBasemap());
// Reconfigure in place after loading the constructor. Keep the visibility
// store and the user's collapsed state steady while replacing the control.
const wasCollapsed = optionsChanged && minimapControl?.getState().collapsed === true;
if (optionsChanged && minimapControl && minimapControlMounted) {
app.removeMapControl(minimapControl);
minimapControl = null;
minimapControlMounted = false;
}
minimapControl ??= createMinimapControl(
MinimapControlClass,
app.getActiveBasemap(),
wasCollapsed,
);
if (!minimapControlMounted) {
const added = app.addMapControl(minimapControl, minimapControlPosition);
if (!added) {
minimapControl = null;
removeMinimapControl(app);
return false;
}
minimapControlMounted = true;
@@ -61,10 +103,11 @@ async function openStandaloneMinimapControl(app: GeoLibreAppAPI): Promise<boolea
});
}
const control = minimapControl;
setTimeout(() => {
if (!minimapControl) return;
minimapControl.show();
minimapControl.expand();
if (request !== minimapRequest || minimapControl !== control) return;
control.show();
if (!wasCollapsed) control.expand();
setMinimapPanelVisible(true);
}, 0);
return true;
@@ -89,7 +132,7 @@ async function refreshMinimapBasemap(app: GeoLibreAppAPI): Promise<void> {
const wasCollapsed = minimapControl.getState().collapsed;
app.removeMapControl(minimapControl);
minimapControl = createMinimapControl(MinimapControlClass, app.getActiveBasemap());
minimapControl = createMinimapControl(MinimapControlClass, app.getActiveBasemap(), wasCollapsed);
const added = app.addMapControl(minimapControl, minimapControlPosition);
if (!added) {
// Also drop the basemap subscription: minimapControlMounted is now false,
@@ -103,25 +146,43 @@ async function refreshMinimapBasemap(app: GeoLibreAppAPI): Promise<void> {
return;
}
const control = minimapControl;
setTimeout(() => {
if (!minimapControl) return;
minimapControl.show();
if (!wasCollapsed) minimapControl.expand();
if (minimapControl !== control) return;
control.show();
if (!wasCollapsed) control.expand();
}, 0);
}
function createMinimapControl(
MinimapControlClass: MinimapControlConstructor,
basemapStyleUrl: string,
collapsed = false,
): MinimapControl {
const control = new MinimapControlClass({
...MINIMAP_OPTIONS,
...minimapOptions,
collapsed,
style: basemapStyleUrl,
});
return control;
}
export function teardownMinimapControl(app: GeoLibreAppAPI): void {
++minimapRequest;
removeMinimapControl(app);
minimapOptions = defaultMinimapOptions();
}
function defaultMinimapOptions(): Required<GeoLibreMinimapOptions> {
return {
zoomOffset: MINIMAP_OPTIONS.zoomOffset,
width: MINIMAP_OPTIONS.width,
height: MINIMAP_OPTIONS.height,
};
}
function removeMinimapControl(app: GeoLibreAppAPI): void {
minimapBasemapUnsubscribe?.();
minimapBasemapUnsubscribe = null;
if (minimapControl && minimapControlMounted) {
@@ -23,6 +23,7 @@ const PRINT_OPTIONS = {
let printControl: PrintControl | null = null;
let printControlMounted = false;
let printPanelVisible = false;
let printRequest = 0;
const printPanelListeners = new Set<() => void>();
let printThemeObserver: MutationObserver | null = null;
@@ -49,8 +50,12 @@ export function subscribePrintPanel(listener: () => void): () => void {
return () => printPanelListeners.delete(listener);
}
async function openStandalonePrintControl(app: GeoLibreAppAPI): Promise<boolean> {
async function openStandalonePrintControl(
app: GeoLibreAppAPI,
request = ++printRequest,
): Promise<boolean> {
const { PrintControl: PrintControlClass } = await getComponentsConstructors();
if (request !== printRequest) return false;
printControl ??= createPrintControl(PrintControlClass);
@@ -64,13 +69,14 @@ async function openStandalonePrintControl(app: GeoLibreAppAPI): Promise<boolean>
startPrintThemeSync();
}
const control = printControl;
setTimeout(() => {
// Guard against a teardown that nulled printControl between addMapControl
// succeeding and this deferred callback firing, which would otherwise mark
// the panel visible even though the control no longer exists.
if (!printControl) return;
printControl.show();
printControl.expand();
if (request !== printRequest || printControl !== control) return;
control.show();
control.expand();
setPrintPanelVisible(true);
}, 0);
return true;
@@ -121,6 +127,7 @@ function stopPrintThemeSync(): void {
}
export function teardownPrintControl(app: GeoLibreAppAPI): void {
++printRequest;
stopPrintThemeSync();
if (printControl && printControlMounted) {
app.removeMapControl(printControl);
@@ -131,6 +138,7 @@ export function teardownPrintControl(app: GeoLibreAppAPI): void {
}
function hidePrintControl(): void {
++printRequest;
printControl?.hide();
setPrintPanelVisible(false);
}
@@ -20,6 +20,7 @@ const SEARCH_OPTIONS = {
let searchControl: SearchControl | null = null;
let searchControlMounted = false;
let searchPlacesPanelVisible = false;
let searchRequest = 0;
const searchPlacesPanelListeners = new Set<() => void>();
// The standalone Search panel is intentionally independent from the
@@ -41,8 +42,12 @@ export function subscribeSearchPlacesPanel(listener: () => void): () => void {
return () => searchPlacesPanelListeners.delete(listener);
}
async function openStandaloneSearchControl(app: GeoLibreAppAPI): Promise<boolean> {
async function openStandaloneSearchControl(
app: GeoLibreAppAPI,
request = ++searchRequest,
): Promise<boolean> {
const { SearchControl: SearchControlClass } = await getComponentsConstructors();
if (request !== searchRequest) return false;
searchControl ??= createSearchControl(SearchControlClass);
@@ -55,9 +60,11 @@ async function openStandaloneSearchControl(app: GeoLibreAppAPI): Promise<boolean
searchControlMounted = true;
}
const control = searchControl;
setTimeout(() => {
searchControl?.show();
searchControl?.expand();
if (request !== searchRequest || searchControl !== control) return;
control.show();
control.expand();
setSearchPlacesPanelVisible(true);
}, 0);
return true;
@@ -74,6 +81,7 @@ function createSearchControl(SearchControlClass: SearchControlConstructor): Sear
}
export function teardownSearchControl(app: GeoLibreAppAPI): void {
++searchRequest;
if (searchControl && searchControlMounted) {
app.removeMapControl(searchControl);
}
@@ -83,6 +91,7 @@ export function teardownSearchControl(app: GeoLibreAppAPI): void {
}
function hideSearchControl(): void {
++searchRequest;
searchControl?.hide();
setSearchPlacesPanelVisible(false);
}
+27
View File
@@ -23,6 +23,19 @@ import type { GeoLibreToolbarLabel } from "./toolbar-menu-label";
export type GeoLibreMapControlPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
/** Built-in panels shared by plugins and the scripting API. */
export type GeoLibreControlPanelId = "bookmark" | "search" | "measure" | "minimap" | "print";
/** Options for the host's overview map. */
export interface GeoLibreMinimapOptions {
/** Zoom relative to the main map; negative values show more context. Default -4. */
zoomOffset?: number;
/** Width in pixels. Must be positive. Default 250. */
width?: number;
/** Height in pixels. Must be positive. Default 180. */
height?: number;
}
export type GeoLibreBuiltInMapControl =
| "navigation"
| "fullscreen"
@@ -1178,6 +1191,20 @@ export interface GeoLibreAppAPI {
unregisterExternalNativeLayer?: (id: string) => void;
addMapControl: (control: IControl, position?: GeoLibreMapControlPosition) => boolean;
removeMapControl: (control: IControl) => void;
/**
* Open a built-in panel using the host's existing control. Idempotent; the
* menu checkmark follows. Minimap options also update an already open map
* and survive basemap changes until it is closed. Controls initialize
* asynchronously. Optional on older hosts.
*/
openControlPanel?: {
(id: "minimap", options: GeoLibreMinimapOptions): void;
(id: GeoLibreControlPanelId): void;
};
/** Close a built-in panel, as its menu toggle does. Optional on older hosts. */
closeControlPanel?: (id: GeoLibreControlPanelId) => void;
/** Read the built-in panel's menu visibility state. Optional on older hosts. */
isControlPanelVisible?: (id: GeoLibreControlPanelId) => boolean;
setBuiltInMapControlVisible: (control: GeoLibreBuiltInMapControl, visible: boolean) => boolean;
getBuiltInMapControlPosition: (control: GeoLibreBuiltInMapControl) => GeoLibreMapControlPosition;
setBuiltInMapControlPosition: (
+34
View File
@@ -165,6 +165,9 @@ function fakeHost(overrides: Partial<AppApiHost> = {}): { host: AppApiHost; call
},
getPrimaryCesiumControlHost: () => null,
addRasterToMap: unused("addRasterToMap"),
openControlPanel: unused("openControlPanel"),
closeControlPanel: unused("closeControlPanel"),
isControlPanelVisible: () => false,
readRasterWindow: unused("readRasterWindow"),
setRasterRenderEngine: unused("setRasterRenderEngine"),
addZarrRasterLayer: unused("addZarrRasterLayer"),
@@ -269,6 +272,37 @@ describe("plugin app API contract", () => {
});
});
describe("plugin app API built-in control panels", () => {
it("opens, closes, and queries every panel through the host with the live app", () => {
const calls: unknown[][] = [];
const visible = new Set<string>();
const { host } = fakeHost({
openControlPanel: (app, id, options) => {
calls.push(["open", app, id, options]);
visible.add(id);
},
closeControlPanel: (app, id) => {
calls.push(["close", app, id]);
visible.delete(id);
},
isControlPanelVisible: (id) => visible.has(id),
});
const api = createAppAPI(engineRef(), host);
for (const id of ["bookmark", "search", "measure", "minimap", "print"] as const) {
assert.equal(api.isControlPanelVisible(id), false);
api.openControlPanel(id);
assert.equal(api.isControlPanelVisible(id), true);
assert.deepEqual(calls.at(-1), ["open", api, id, undefined]);
api.closeControlPanel(id);
assert.equal(api.isControlPanelVisible(id), false);
assert.deepEqual(calls.at(-1), ["close", api, id]);
}
const options = { zoomOffset: -6, width: 320, height: 220 };
api.openControlPanel("minimap", options);
assert.deepEqual(calls.at(-1), ["open", api, "minimap", options]);
});
});
describe("plugin app API map tools", () => {
beforeEach(() => {
useAppStore.getState().newProject({ name: "App API map tools" });
+274
View File
@@ -0,0 +1,274 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
closeControlPanel,
isControlPanelVisible,
openControlPanel,
} from "../packages/plugins/src/control-panels";
import {
__setComponentsModuleLoaderForTests,
getComponentsConstructors,
type ComponentsModules,
} from "../packages/plugins/src/plugins/components/constructors";
import {
openMinimapPanel,
subscribeMinimapPanel,
} from "../packages/plugins/src/plugins/components/minimap";
import type { GeoLibreAppAPI, GeoLibreControlPanelId } from "../packages/plugins/src/types";
import { teardownPrintControl } from "../packages/plugins/src/plugins/components/print";
import { teardownSearchControl } from "../packages/plugins/src/plugins/components/search";
class Panel {
on() {
return this;
}
show() {
return this;
}
hide() {
return this;
}
expand() {
return this;
}
}
class Overview {
collapsed: boolean;
constructor(readonly options: Record<string, unknown>) {
this.collapsed = options.collapsed === true;
created.push(this);
}
show() {
return this;
}
expand() {
this.collapsed = false;
return this;
}
getState() {
return { visible: true, collapsed: this.collapsed };
}
}
const created: Overview[] = [];
const mounted = new Set<unknown>();
let basemap = "https://example.com/initial.json";
let onBasemapChange: (() => void) | undefined;
let canMount = true;
const app = {
getActiveBasemap: () => basemap,
addMapControl: (control: unknown) => {
if (!canMount) return false;
mounted.add(control);
return true;
},
removeMapControl: (control: unknown) => mounted.delete(control),
onBasemapChange: (callback: () => void) => {
onBasemapChange = callback;
return () => {
onBasemapChange = undefined;
};
},
} as unknown as GeoLibreAppAPI;
const modules = [
{
MinimapControl: Overview,
BookmarkControl: Panel,
SearchControl: Panel,
MeasureControl: Panel,
PrintControl: Panel,
},
null,
] as unknown as ComponentsModules;
// Flush the lazy import and the control's deferred show/expand callback.
async function settle() {
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("built-in control panel API", () => {
beforeEach(() => {
created.length = 0;
mounted.clear();
canMount = true;
basemap = "https://example.com/initial.json";
__setComponentsModuleLoaderForTests(() => Promise.resolve(modules));
});
afterEach(() => {
for (const id of ["bookmark", "search", "measure", "minimap", "print"] as const)
closeControlPanel(app, id);
teardownPrintControl(app);
teardownSearchControl(app);
__setComponentsModuleLoaderForTests(null);
});
it("uses the menu's visibility store and creates only one overview on repeated opens", async () => {
let changes = 0;
const unsubscribe = subscribeMinimapPanel(() => changes++);
try {
assert.equal(isControlPanelVisible("minimap"), false);
openControlPanel(app, "minimap", { zoomOffset: -6, width: 320, height: 220 });
openControlPanel(app, "minimap");
await settle();
openControlPanel(app, "minimap");
await settle();
assert.equal(created.length, 1);
assert.equal(mounted.size, 1);
assert.equal(isControlPanelVisible("minimap"), true);
assert.equal(changes, 1);
assert.equal(created[0].options.zoomOffset, -6);
assert.equal(created[0].options.width, 320);
assert.equal(created[0].options.height, 220);
closeControlPanel(app, "minimap");
closeControlPanel(app, "minimap");
assert.equal(mounted.size, 0);
assert.equal(isControlPanelVisible("minimap"), false);
assert.equal(changes, 2);
} finally {
unsubscribe();
}
});
it("updates an open overview and keeps its options and collapsed state across basemap changes", async () => {
openControlPanel(app, "minimap", { zoomOffset: -6, width: 320 });
await settle();
created[0].collapsed = true;
const visibilityChanges: boolean[] = [];
const unsubscribe = subscribeMinimapPanel(() =>
visibilityChanges.push(isControlPanelVisible("minimap")),
);
try {
openControlPanel(app, "minimap", { height: 240 });
await settle();
assert.equal(mounted.size, 1);
assert.equal(created.length, 2);
assert.equal(created[1].collapsed, true);
assert.deepEqual(visibilityChanges, []);
} finally {
unsubscribe();
}
basemap = "https://example.com/next.json";
onBasemapChange?.();
await settle();
assert.equal(created.length, 3);
assert.equal(mounted.size, 1);
const latest = created[2];
assert.equal(latest.options.style, basemap);
assert.equal(latest.options.zoomOffset, -6);
assert.equal(latest.options.width, 320);
assert.equal(latest.options.height, 240);
assert.equal(latest.collapsed, true);
});
it("keeps a collapsed overview collapsed on a repeated direct menu open", async () => {
openMinimapPanel(app);
openMinimapPanel(app);
await settle();
created[0].collapsed = true;
openMinimapPanel(app);
await settle();
assert.equal(created.length, 1);
assert.equal(created[0].collapsed, true);
});
it("resets options after close so a later menu open uses the defaults", async () => {
openControlPanel(app, "minimap", { zoomOffset: -6, width: 320, height: 220 });
await settle();
closeControlPanel(app, "minimap");
assert.equal(onBasemapChange, undefined);
openControlPanel(app, "minimap");
await settle();
assert.equal(created[1].options.zoomOffset, -4);
assert.equal(created[1].options.width, 250);
assert.equal(created[1].options.height, 180);
});
it("does not reopen after close while constructors are loading", async () => {
let resolve!: (value: ComponentsModules) => void;
__setComponentsModuleLoaderForTests(
() =>
new Promise((done) => {
resolve = done;
}),
);
openControlPanel(app, "minimap");
closeControlPanel(app, "minimap");
resolve(modules);
await settle();
assert.equal(created.length, 0);
assert.equal(mounted.size, 0);
assert.equal(isControlPanelVisible("minimap"), false);
});
for (const id of ["bookmark", "search", "measure", "print"] as const) {
it(`cancels ${id} when closed before its constructors load`, async () => {
let resolve!: (value: ComponentsModules) => void;
__setComponentsModuleLoaderForTests(
() =>
new Promise((done) => {
resolve = done;
}),
);
openControlPanel(app, id);
closeControlPanel(app, id);
resolve(modules);
await settle();
assert.equal(mounted.size, 0);
assert.equal(isControlPanelVisible(id), false);
});
it(`does not show ${id} after close between mounting and its deferred show`, async () => {
openControlPanel(app, id);
await getComponentsConstructors();
assert.equal(mounted.size, 1);
closeControlPanel(app, id);
await settle();
assert.equal(isControlPanelVisible(id), false);
});
}
it("lets the latest configuration win during concurrent opens", async () => {
openControlPanel(app, "minimap", { zoomOffset: -5 });
openControlPanel(app, "minimap", { zoomOffset: -7 });
await settle();
assert.equal(created.length, 1);
assert.equal(created[0].options.zoomOffset, -7);
assert.equal(mounted.size, 1);
});
it("keeps visibility false when the renderer cannot mount the control", async () => {
canMount = false;
openControlPanel(app, "minimap");
await settle();
assert.equal(mounted.size, 0);
assert.equal(isControlPanelVisible("minimap"), false);
assert.equal(onBasemapChange, undefined);
});
it("rejects invalid dimensions and non-finite offsets before changing an open control", async () => {
openControlPanel(app, "minimap");
await settle();
for (const options of [
{ width: 0 },
{ height: -1 },
{ width: NaN },
{ zoomOffset: Infinity },
]) {
assert.throws(() => openControlPanel(app, "minimap", options), /must be a finite/);
}
assert.equal(mounted.size, 1);
assert.equal(created.length, 1);
assert.equal(isControlPanelVisible("minimap"), true);
});
it("rejects unknown panel ids, including object prototype members", () => {
for (const id of ["unknown", "toString", "__proto__"]) {
const invalid = id as GeoLibreControlPanelId;
assert.throws(() => openControlPanel(app, invalid), /Unknown control panel/);
assert.throws(() => closeControlPanel(app, invalid), /Unknown control panel/);
assert.throws(() => isControlPanelVisible(invalid), /Unknown control panel/);
}
});
});