diff --git a/package.json b/package.json index b4e407d..0f57caf 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", - "tailwindcss": "^4.3.1" + "tailwindcss": "^4.3.1", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ba8014..58e51d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: tailwindcss: specifier: ^4.3.1 version: 4.3.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@eslint/js': specifier: ^10.0.1 diff --git a/src/app.test.tsx b/src/app.test.tsx index 5bdb108..5f1e265 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => { theme: "dark", mapStyle: "light", externalMapProvider: "apple", + zoom: 13, }; return { @@ -101,6 +102,8 @@ describe("App settings access", () => { "OpenStreetMap", "Google Maps", "Apple Maps", + "AMap", + "Baidu Maps", ]); }); }); diff --git a/src/components/external-map-link.test.tsx b/src/components/external-map-link.test.tsx index be3374a..e6b3208 100644 --- a/src/components/external-map-link.test.tsx +++ b/src/components/external-map-link.test.tsx @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ theme: "eagle", mapStyle: "auto", externalMapProvider: "openstreetmap", + zoom: 13, }, })); diff --git a/src/components/mini-map.test.tsx b/src/components/mini-map.test.tsx index 12d9b4d..b609fcb 100644 --- a/src/components/mini-map.test.tsx +++ b/src/components/mini-map.test.tsx @@ -22,12 +22,16 @@ const mocks = vi.hoisted(() => { theme: "eagle", mapStyle: "auto", externalMapProvider: "openstreetmap", + zoom: 13, }; return { settings, isDark: false, recordMapProps: vi.fn<(props: MockMapProps) => void>(), + updateSettings: vi.fn((patch: Partial) => { + Object.assign(settings, patch); + }), }; }); @@ -38,7 +42,7 @@ vi.mock("../hooks/use-is-dark-theme", () => ({ vi.mock("../hooks/use-plugin-settings", () => ({ usePluginSettings: () => ({ settings: mocks.settings, - updateSettings: vi.fn(), + updateSettings: mocks.updateSettings, }), })); @@ -50,11 +54,12 @@ vi.mock("react-map-gl/maplibre", () => ({ })); beforeEach(() => { - mocks.settings = { + Object.assign(mocks.settings, { theme: "eagle", mapStyle: "auto", externalMapProvider: "openstreetmap", - }; + zoom: 13, + }); mocks.isDark = false; }); @@ -63,7 +68,15 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("MiniMap map style", () => { +describe("MiniMap", () => { + it("uses the persisted zoom level", () => { + mocks.settings.zoom = 16; + + render(); + + expect(mocks.recordMapProps.mock.lastCall?.[0].zoom).toBe(16); + }); + it.each([ ["auto in a light plugin theme", "auto", false, MAP_STYLE_LIGHT], ["auto in a dark plugin theme", "auto", true, MAP_STYLE_DARK], @@ -95,6 +108,7 @@ describe("MiniMap map style", () => { mocks.settings.mapStyle = "light"; rerender(); + expect(mocks.updateSettings).toHaveBeenCalledWith({ zoom: 14 }); expect(screen.getByTestId("map")).toBe(mapElement); expect(mocks.recordMapProps.mock.lastCall?.[0]).toMatchObject({ latitude: 1.3521, diff --git a/src/components/mini-map.tsx b/src/components/mini-map.tsx index 446325a..4ca5000 100644 --- a/src/components/mini-map.tsx +++ b/src/components/mini-map.tsx @@ -3,7 +3,7 @@ import { useCallback, useState } from "react"; import Map from "react-map-gl/maplibre"; import { useIsDarkTheme } from "../hooks/use-is-dark-theme"; import { usePluginSettings } from "../hooks/use-plugin-settings"; -import type { MapStylePreference } from "../lib/plugin-settings"; +import { MAP_ZOOM, type MapStylePreference } from "../lib/plugin-settings"; import type { Coordinates } from "../types"; import { ZoomControls } from "./zoom-controls"; @@ -15,9 +15,6 @@ const MAP_STYLE_LIGHT = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; const MAP_STYLE_DARK = "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"; -const MIN_ZOOM = 2; -const MAX_ZOOM = 18; -const DEFAULT_ZOOM = 13; function resolveMapStyle( preference: MapStylePreference, @@ -30,10 +27,10 @@ function resolveMapStyle( export function MiniMap({ latitude, longitude, label }: MiniMapProps) { const isDark = useIsDarkTheme(); - const { settings } = usePluginSettings(); + const { settings, updateSettings } = usePluginSettings(); const mapStyle = resolveMapStyle(settings.mapStyle, isDark); const [isLoaded, setIsLoaded] = useState(false); - const [zoom, setZoom] = useState(DEFAULT_ZOOM); + const zoom = settings.zoom; const handleLoad = useCallback( (event: { target: { resize: () => void } }) => { @@ -44,19 +41,17 @@ export function MiniMap({ latitude, longitude, label }: MiniMapProps) { [], ); - const clampZoom = useCallback((value: number) => { - return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value)); - }, []); - const handleZoomChange = useCallback( (delta: number) => { - setZoom((prev) => clampZoom(prev + delta)); + updateSettings({ + zoom: Math.min(MAP_ZOOM.max, Math.max(MAP_ZOOM.min, zoom + delta)), + }); }, - [clampZoom], + [updateSettings, zoom], ); - const canZoomIn = zoom < MAX_ZOOM; - const canZoomOut = zoom > MIN_ZOOM; + const canZoomIn = zoom < MAP_ZOOM.max; + const canZoomOut = zoom > MAP_ZOOM.min; return (
{ theme: "dark", mapStyle: "light", externalMapProvider: "apple", + zoom: 13, }; return { settings, updateSettings: vi.fn() }; diff --git a/src/components/settings-menu.tsx b/src/components/settings-menu.tsx index eab1997..4e5ddd1 100644 --- a/src/components/settings-menu.tsx +++ b/src/components/settings-menu.tsx @@ -9,8 +9,11 @@ import { } from "react"; import { useIsDarkTheme } from "../hooks/use-is-dark-theme"; import { usePluginSettings } from "../hooks/use-plugin-settings"; +import { + EXTERNAL_MAP_PROVIDER_OPTIONS, + type ExternalMapProvider, +} from "../lib/external-map"; import type { - ExternalMapProvider, MapStylePreference, ThemePreference, } from "../lib/plugin-settings"; @@ -40,12 +43,6 @@ const MAP_STYLE_OPTIONS = [ { value: "dark", label: "Dark" }, ] as const satisfies readonly SettingOption[]; -const EXTERNAL_MAP_PROVIDER_OPTIONS = [ - { value: "openstreetmap", label: "OpenStreetMap" }, - { value: "google", label: "Google Maps" }, - { value: "apple", label: "Apple Maps" }, -] as const satisfies readonly SettingOption[]; - const SETTINGS_DIALOG_ID = "settings-dialog"; function useDismissibleLayer( diff --git a/src/hooks/use-plugin-settings.test.tsx b/src/hooks/use-plugin-settings.test.tsx index a9f1487..4874d81 100644 --- a/src/hooks/use-plugin-settings.test.tsx +++ b/src/hooks/use-plugin-settings.test.tsx @@ -34,6 +34,7 @@ describe("PluginSettingsProvider", () => { theme: "dark", mapStyle: "light", externalMapProvider: "apple", + zoom: 16, }), ); const getItem = vi.spyOn(Storage.prototype, "getItem"); @@ -49,6 +50,7 @@ describe("PluginSettingsProvider", () => { theme: "dark", mapStyle: "light", externalMapProvider: "apple", + zoom: 16, }); }); @@ -87,6 +89,7 @@ describe("PluginSettingsProvider", () => { theme: "eagle", mapStyle: "dark", externalMapProvider: "google", + zoom: 13, }), ); }); diff --git a/src/hooks/use-plugin-theme.test.tsx b/src/hooks/use-plugin-theme.test.tsx index 29162c0..8c1b0f1 100644 --- a/src/hooks/use-plugin-theme.test.tsx +++ b/src/hooks/use-plugin-theme.test.tsx @@ -88,6 +88,7 @@ function storeTheme(theme: ThemePreference): void { theme, mapStyle: "auto", externalMapProvider: "openstreetmap", + zoom: 13, }), ); } diff --git a/src/lib/external-map.test.ts b/src/lib/external-map.test.ts index d7a7bcf..774e591 100644 --- a/src/lib/external-map.test.ts +++ b/src/lib/external-map.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ExternalMapProvider } from "./plugin-settings"; -import { getExternalMap } from "./external-map"; +import { getExternalMap, type ExternalMapProvider } from "./external-map"; import type { Coordinates } from "../types"; interface ExternalMapTestCase { @@ -37,6 +36,22 @@ const testCases: ExternalMapTestCase[] = [ url: "https://maps.apple.com/?ll=-33.8688%2C151.2093&z=16&q=Location", }, }, + { + provider: "amap", + coordinates: { latitude: -33.8688, longitude: 151.2093 }, + expected: { + label: "AMap", + url: "https://uri.amap.com/marker?position=151.2093%2C-33.8688&name=Location&src=eagle-plugin-mini-map&coordinate=wgs84&callnative=0", + }, + }, + { + provider: "baidu", + coordinates: { latitude: -33.8688, longitude: 151.2093 }, + expected: { + label: "Baidu Maps", + url: "https://api.map.baidu.com/marker?location=-33.8688%2C151.2093&title=Location&content=Location&output=html&coord_type=wgs84&src=webapp.eagle.mini-map", + }, + }, ]; describe("getExternalMap", () => { diff --git a/src/lib/external-map.ts b/src/lib/external-map.ts index d1d3096..3a83518 100644 --- a/src/lib/external-map.ts +++ b/src/lib/external-map.ts @@ -1,4 +1,4 @@ -import type { ExternalMapProvider } from "./plugin-settings"; +import { z } from "zod"; import type { Coordinates } from "../types"; interface ExternalMap { @@ -6,32 +6,99 @@ interface ExternalMap { url: string; } -export function getExternalMap( - provider: ExternalMapProvider, - coordinates: Coordinates, -): ExternalMap { - const { latitude, longitude } = coordinates; +interface ExternalMapProviderDefinition { + label: string; + buildUrl: (coordinates: Coordinates) => string; +} - switch (provider) { - case "openstreetmap": { +const EXTERNAL_MAP_PROVIDERS = { + openstreetmap: { + label: "OpenStreetMap", + buildUrl: ({ latitude, longitude }) => { const url = new URL("https://www.openstreetmap.org/"); url.searchParams.set("mlat", String(latitude)); url.searchParams.set("mlon", String(longitude)); url.searchParams.set("zoom", "16"); - return { label: "OpenStreetMap", url: url.toString() }; - } - case "google": { + return url.toString(); + }, + }, + google: { + label: "Google Maps", + buildUrl: ({ latitude, longitude }) => { const url = new URL("https://www.google.com/maps/search/"); url.searchParams.set("api", "1"); url.searchParams.set("query", `${latitude},${longitude}`); - return { label: "Google Maps", url: url.toString() }; - } - case "apple": { + return url.toString(); + }, + }, + apple: { + label: "Apple Maps", + buildUrl: ({ latitude, longitude }) => { const url = new URL("https://maps.apple.com/"); url.searchParams.set("ll", `${latitude},${longitude}`); url.searchParams.set("z", "16"); url.searchParams.set("q", "Location"); - return { label: "Apple Maps", url: url.toString() }; - } - } + return url.toString(); + }, + }, + amap: { + label: "AMap", + buildUrl: ({ latitude, longitude }) => { + const url = new URL("https://uri.amap.com/marker"); + url.searchParams.set("position", `${longitude},${latitude}`); + url.searchParams.set("name", "Location"); + url.searchParams.set("src", "eagle-plugin-mini-map"); + // Treat the plugin's EXIF GPS coordinates as WGS-84; AMap otherwise + // defaults to GCJ-02. + // See: https://developer.amap.com/api/uri-api/gettingstarted + url.searchParams.set("coordinate", "wgs84"); + url.searchParams.set("callnative", "0"); + return url.toString(); + }, + }, + baidu: { + label: "Baidu Maps", + buildUrl: ({ latitude, longitude }) => { + const url = new URL("https://api.map.baidu.com/marker"); + url.searchParams.set("location", `${latitude},${longitude}`); + url.searchParams.set("title", "Location"); + url.searchParams.set("content", "Location"); + url.searchParams.set("output", "html"); + // Treat the plugin's EXIF GPS coordinates as WGS-84; Baidu otherwise + // defaults to BD-09. + // See: https://lbsyun.baidu.com/faq/api?title=webapi%2Furi%2Fweb + url.searchParams.set("coord_type", "wgs84"); + url.searchParams.set("src", "webapp.eagle.mini-map"); + return url.toString(); + }, + }, +} satisfies Record; + +export type ExternalMapProvider = keyof typeof EXTERNAL_MAP_PROVIDERS; + +interface ExternalMapProviderOption { + readonly value: ExternalMapProvider; + readonly label: string; +} + +export const EXTERNAL_MAP_PROVIDER_OPTIONS: readonly ExternalMapProviderOption[] = + Object.entries(EXTERNAL_MAP_PROVIDERS).map(([value, { label }]) => ({ + value: value as ExternalMapProvider, + label, + })); + +export const externalMapProviderSchema = z.enum( + EXTERNAL_MAP_PROVIDER_OPTIONS.map(({ value }) => value), +); + +export function getExternalMap( + provider: ExternalMapProvider, + coordinates: Coordinates, +): ExternalMap { + const definition = EXTERNAL_MAP_PROVIDERS[provider]; + + return { + label: definition.label, + url: definition.buildUrl(coordinates), + }; } diff --git a/src/lib/plugin-settings.test.ts b/src/lib/plugin-settings.test.ts index 7faa54c..bbabf6f 100644 --- a/src/lib/plugin-settings.test.ts +++ b/src/lib/plugin-settings.test.ts @@ -56,6 +56,7 @@ describe("readPluginSettings", () => { theme: "dark", mapStyle: "light", externalMapProvider: "apple", + zoom: 16, unknown: "ignored", }), ); @@ -64,14 +65,27 @@ describe("readPluginSettings", () => { theme: "dark", mapStyle: "light", externalMapProvider: "apple", + zoom: 16, }); }); + it.each(["amap", "baidu"] as const)( + "accepts the %s external map provider", + (externalMapProvider) => { + const storage = readableStorage(JSON.stringify({ externalMapProvider })); + + expect(readPluginSettings(storage).externalMapProvider).toBe( + externalMapProvider, + ); + }, + ); + it("keeps valid fields when other fields are missing or invalid", () => { const storage = readableStorage( JSON.stringify({ theme: "light", mapStyle: "satellite", + zoom: 19, }), ); @@ -79,6 +93,7 @@ describe("readPluginSettings", () => { theme: "light", mapStyle: "auto", externalMapProvider: "openstreetmap", + zoom: 13, }); }); @@ -114,6 +129,7 @@ describe("writePluginSettings", () => { theme: "dark", mapStyle: "dark", externalMapProvider: "google", + zoom: 15, }; writePluginSettings(settings, storage); diff --git a/src/lib/plugin-settings.ts b/src/lib/plugin-settings.ts index 2e1339b..ef6c536 100644 --- a/src/lib/plugin-settings.ts +++ b/src/lib/plugin-settings.ts @@ -1,36 +1,43 @@ -export type ThemePreference = "eagle" | "light" | "dark"; -export type MapStylePreference = "auto" | "light" | "dark"; -export type ExternalMapProvider = "openstreetmap" | "google" | "apple"; +import { z } from "zod"; +import { externalMapProviderSchema } from "./external-map"; -export interface PluginSettings { - theme: ThemePreference; - mapStyle: MapStylePreference; - externalMapProvider: ExternalMapProvider; -} +export const MAP_ZOOM = { + min: 2, + max: 18, + default: 13, +} as const; -export const DEFAULT_PLUGIN_SETTINGS: Readonly = Object.freeze({ +export const DEFAULT_PLUGIN_SETTINGS = Object.freeze({ theme: "eagle", mapStyle: "auto", externalMapProvider: "openstreetmap", -}); - -export const PLUGIN_SETTINGS_STORAGE_KEY = "eagle-plugin-mini-map:settings:v1"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} + zoom: MAP_ZOOM.default, +} as const); -function isThemePreference(value: unknown): value is ThemePreference { - return value === "eagle" || value === "light" || value === "dark"; -} +const pluginSettingsSchema = z + .object({ + theme: z + .enum(["eagle", "light", "dark"]) + .catch(DEFAULT_PLUGIN_SETTINGS.theme), + mapStyle: z + .enum(["auto", "light", "dark"]) + .catch(DEFAULT_PLUGIN_SETTINGS.mapStyle), + externalMapProvider: externalMapProviderSchema.catch( + DEFAULT_PLUGIN_SETTINGS.externalMapProvider, + ), + zoom: z + .number() + .min(MAP_ZOOM.min) + .max(MAP_ZOOM.max) + .catch(DEFAULT_PLUGIN_SETTINGS.zoom), + }) + .catch(DEFAULT_PLUGIN_SETTINGS); -function isMapStylePreference(value: unknown): value is MapStylePreference { - return value === "auto" || value === "light" || value === "dark"; -} +export type PluginSettings = z.infer; +export type ThemePreference = PluginSettings["theme"]; +export type MapStylePreference = PluginSettings["mapStyle"]; -function isExternalMapProvider(value: unknown): value is ExternalMapProvider { - return value === "openstreetmap" || value === "google" || value === "apple"; -} +export const PLUGIN_SETTINGS_STORAGE_KEY = "eagle-plugin-mini-map:settings:v1"; export function readPluginSettings( storage?: Pick, @@ -42,19 +49,7 @@ export function readPluginSettings( if (serialized === null) return { ...DEFAULT_PLUGIN_SETTINGS }; const stored: unknown = JSON.parse(serialized); - if (!isRecord(stored)) return { ...DEFAULT_PLUGIN_SETTINGS }; - - return { - theme: isThemePreference(stored.theme) - ? stored.theme - : DEFAULT_PLUGIN_SETTINGS.theme, - mapStyle: isMapStylePreference(stored.mapStyle) - ? stored.mapStyle - : DEFAULT_PLUGIN_SETTINGS.mapStyle, - externalMapProvider: isExternalMapProvider(stored.externalMapProvider) - ? stored.externalMapProvider - : DEFAULT_PLUGIN_SETTINGS.externalMapProvider, - }; + return { ...pluginSettingsSchema.parse(stored) }; } catch { return { ...DEFAULT_PLUGIN_SETTINGS }; }