From 74f3a30be39a0dcf75b877f17ff77a2491cdad01 Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 10 Jun 2026 21:23:19 +0800 Subject: [PATCH] fix(plugin-map): restore v1 Google Maps selection (#9735) --- .../client/components/GoogleMaps/Block.tsx | 164 +++++++--- .../src/client/components/GoogleMaps/Map.tsx | 21 +- .../src/client/googleMapsDrawingManager.ts | 307 ++++++++++++++++++ 3 files changed, 443 insertions(+), 49 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-map/src/client/googleMapsDrawingManager.ts diff --git a/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Block.tsx b/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Block.tsx index 41d6e669717..624b075c9e7 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Block.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Block.tsx @@ -28,7 +28,7 @@ import { defaultImage, selectedImage } from '../../constants'; import { useMapTranslation } from '../../locale'; import { getSource } from '../../utils'; import { MapBlockDrawer } from '../MapBlockDrawer'; -import { GoogleMapForwardedRefProps, GoogleMapsComponent, OverlayOptions } from './Map'; +import { GoogleMapForwardedRefProps, GoogleMapsComponent } from './Map'; import { getIcon } from './utils'; const OVERLAY_KEY = 'google-maps-overlay-id'; @@ -48,9 +48,43 @@ const pointClass = css` border: 1px solid #0000f5; `; +const isPositionInPolygon = (position: google.maps.LatLng, polygonPath: google.maps.LatLng[]) => { + const x = position.lng(); + const y = position.lat(); + let inside = false; + + for (let i = 0, j = polygonPath.length - 1; i < polygonPath.length; j = i++) { + const xi = polygonPath[i].lng(); + const yi = polygonPath[i].lat(); + const xj = polygonPath[j].lng(); + const yj = polygonPath[j].lat(); + const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + + if (intersects) { + inside = !inside; + } + } + + return inside; +}; + +const getMarkerLabel = (text: unknown): google.maps.MarkerLabel | undefined => { + if (typeof text !== 'string' || !text) { + return; + } + + return { + className: labelClass, + fontFamily: 'inherit', + fontSize: '13px', + color: '#333', + text, + } as google.maps.MarkerLabel; +}; + export const GoogleMapsBlock = (props) => { // 新版 UISchema(1.0 之后)中已经废弃了 useProps,这里之所以继续保留是为了兼容旧版的 UISchema - const { collectionField, fieldNames, dataSource, fixedBlock, zoom, setSelectedRecordKeys, lineSort } = + const { collectionField, fieldNames, dataSource, fixedBlock, zoom, setSelectedRecordKeys, lineSort, service } = useProps(props); const { getPrimaryKey } = useCollection_deprecated(); const primaryKey = getPrimaryKey(); @@ -66,6 +100,9 @@ export const GoogleMapsBlock = (props) => { const selectingModeRef = useRef(selectingMode); const selectionOverlayRef = useRef(null); const overlaysRef = useRef([]); + const selectedRecordKeysRef = useRef>(new Set()); + const lastSelectionCompleteAtRef = useRef(0); + const listLoadingRef = useRef(Boolean(service?.loading)); selectingModeRef.current = selectingMode; const { fields } = useCollection(); const parentRecordData = useCollectionParentRecordData(); @@ -74,23 +111,35 @@ export const GoogleMapsBlock = (props) => { const { openPopup } = usePopupUtils(); const setOverlayOptions = (overlay: google.maps.MVCObject, state?: boolean) => { - const selected = typeof state !== 'undefined' ? !state : overlay.get(OVERLAY_SELECtED); - overlay.set(OVERLAY_SELECtED, !selected); - (overlay as google.maps.Marker).setOptions({ - ...(selected + const selected = typeof state !== 'undefined' ? state : !overlay.get(OVERLAY_SELECtED); + overlay.set(OVERLAY_SELECtED, selected); + if (overlay instanceof google.maps.Marker) { + overlay.setIcon(getIcon(selected ? selectedImage : defaultImage)); + overlay.setZIndex(selected ? 138 : null); + return; + } + + (overlay as google.maps.Polygon).setOptions( + selected ? { - icon: getIcon(defaultImage), - strokeColor: '#4e9bff', - fillColor: '#4e9bff', - } - : { - icon: getIcon(selectedImage), strokeColor: '#F18b62', fillColor: '#F18b62', - }), - } as OverlayOptions); + } + : { + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + }, + ); }; + const clearSelectionState = useMemoizedFn(() => { + selectedRecordKeysRef.current.clear(); + overlaysRef.current.forEach((overlay) => { + setOverlayOptions(overlay, false); + }); + setSelectedRecordKeys([]); + }); + // selection useEffect(() => { if (selectingMode !== 'selection') { @@ -102,27 +151,19 @@ export const GoogleMapsBlock = (props) => { draggable: true, }); } - const listenerSet = new Set<() => void>(); - mapRef.current?.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON); - mapRef.current?.drawingManager.addListener('overlaycomplete', (event) => { + mapRef.current?.drawingManager?.setDrawingMode('polygon'); + mapRef.current?.drawingManager?.addListener('overlaycomplete', (event) => { const polygon = event.overlay as google.maps.Polygon; - mapRef.current?.drawingManager.setDrawingMode(null); + mapRef.current?.drawingManager?.setDrawingMode(null); selectionOverlayRef.current = polygon; - const path = polygon.getPath(); - ['insert_at', 'remove_at', 'set_at'].forEach((key) => { - listenerSet.add(path.addListener(key, () => {}).remove); - }); }); return () => { - listenerSet.forEach((i) => { - i(); - }); if (!mapRef.current) return; selectionOverlayRef.current?.unbindAll(); selectionOverlayRef.current?.setMap(null); selectionOverlayRef.current = null; - mapRef.current?.drawingManager.setDrawingMode(null); - mapRef.current?.drawingManager.unbindAll(); + mapRef.current?.drawingManager?.setDrawingMode(null); + mapRef.current?.drawingManager?.unbindAll(); }; }, [selectingMode]); @@ -139,21 +180,30 @@ export const GoogleMapsBlock = (props) => { }, [selectingMode]); const onSelectingComplete = useMemoizedFn(() => { + mapRef.current?.drawingManager?.completeDrawing(); const overlay = selectionOverlayRef.current; + if (!overlay) { + return; + } const overlays = overlaysRef.current; - const poly = google.maps.geometry.poly; + const selectionPath = overlay.getPath().getArray(); + if (selectionPath.length < 3) { + return; + } const selectedOverlays = overlays.filter((o) => { if (o === overlay || o.get(OVERLAY_KEY) === undefined) return; if (o instanceof google.maps.Marker) { - return poly.containsLocation(o.getPosition()!, overlay!); + const position = o.getPosition(); + return position ? isPositionInPolygon(position, selectionPath) : false; } else if (o instanceof google.maps.Circle) { - return poly.containsLocation(o.getCenter()!, overlay!); + const center = o.getCenter(); + return center ? isPositionInPolygon(center, selectionPath) : false; } else { return (o as google.maps.Polygon) .getPath() .getArray() .some((position) => { - return poly.containsLocation(position, overlay!); + return isPositionInPolygon(position, selectionPath); }); } }); @@ -161,10 +211,15 @@ export const GoogleMapsBlock = (props) => { setOverlayOptions(o, true); return o.get(OVERLAY_KEY); }); + ids.forEach((id) => { + selectedRecordKeysRef.current.add(id); + }); + lastSelectionCompleteAtRef.current = Date.now(); setSelectedRecordKeys((lastIds) => ids.concat(lastIds)); - overlay?.unbindAll(); - overlay?.setMap(null); - mapRef.current?.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON); + overlay.unbindAll(); + overlay.setMap(null); + selectionOverlayRef.current = null; + mapRef.current?.drawingManager?.setDrawingMode('polygon'); }); useEffect(() => { @@ -179,6 +234,7 @@ export const GoogleMapsBlock = (props) => { .map((item) => { const data = getSource(item, fieldNames?.field, cf?.interface); const title = getLabelFormatValue(labelUiSchema, item[fieldNames.marker]); + const markerLabel = getMarkerLabel(fieldNames?.marker ? compile(title) : undefined); if (!data?.length) return []; return data?.filter(Boolean).map((mapItem) => { if (!data) return; @@ -186,15 +242,12 @@ export const GoogleMapsBlock = (props) => { strokeColor: '#4e9bff', fillColor: '#4e9bff', cursor: 'pointer', - label: { - className: labelClass, - fontFamily: 'inherit', - fontSize: '13px', - color: '#333', - text: fieldNames?.marker ? compile(title) : undefined, - } as google.maps.MarkerLabel, + label: markerLabel, }); overlay?.set(OVERLAY_KEY, item[primaryKey]); + if (overlay) { + setOverlayOptions(overlay, selectedRecordKeysRef.current.has(item[primaryKey])); + } return overlay; }); }) @@ -205,6 +258,12 @@ export const GoogleMapsBlock = (props) => { const events = overlays.map((o: google.maps.MVCObject) => { const onClick = (event) => { + if (selectingModeRef.current === 'selection') { + const markerPosition = o instanceof google.maps.Marker ? o.getPosition() : undefined; + mapRef.current?.drawingManager?.handleOverlayClick(event, markerPosition); + return; + } + const overlay = o as google.maps.Polygon; const id = overlay.get(OVERLAY_KEY); if (!id) return; @@ -240,7 +299,13 @@ export const GoogleMapsBlock = (props) => { }); } }; + const onDoubleClick = () => { + if (selectingModeRef.current === 'selection') { + mapRef.current?.drawingManager?.handleDoubleClick(); + } + }; o.addListener('click', onClick); + o.addListener('dblclick', onDoubleClick); return () => o.unbindAll(); }); @@ -299,9 +364,22 @@ export const GoogleMapsBlock = (props) => { useEffect(() => { setTimeout(() => { - setSelectedRecordKeys([]); + if (Date.now() - lastSelectionCompleteAtRef.current < 100) { + return; + } + clearSelectionState(); }); - }, [dataSource]); + }, [clearSelectionState, dataSource]); + + useEffect(() => { + const wasLoading = listLoadingRef.current; + const isLoading = Boolean(service?.loading); + listLoadingRef.current = isLoading; + + if (wasLoading && !isLoading) { + clearSelectionState(); + } + }, [clearSelectionState, service?.loading]); const mapRefCallback = (instance: GoogleMapForwardedRefProps) => { mapRef.current = instance; diff --git a/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Map.tsx b/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Map.tsx index 558c47d8190..67043080a1f 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Map.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client/components/GoogleMaps/Map.tsx @@ -15,6 +15,11 @@ import { useMemoizedFn } from 'ahooks'; import { Alert, App, Button, Spin } from 'antd'; import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { defaultImage } from '../../constants'; +import { + createGoogleMapsDrawingManager, + GoogleMapsDrawingManager, + GoogleMapsDrawingMode, +} from '../../googleMapsDrawingManager'; import { useMapConfiguration, useMapConfig } from '../../hooks'; import { useMapTranslation } from '../../locale'; import { MapEditorType } from '../../types'; @@ -80,7 +85,7 @@ export interface GoogleMapForwardedRefProps { createDraw: (onlyCreate?: boolean, additionalOptions?: OverlayOptions) => any; map: google.maps.Map; overlay: google.maps.MVCObject; - drawingManager: google.maps.drawing.DrawingManager; + drawingManager: GoogleMapsDrawingManager; errMessage?: string; } @@ -100,7 +105,7 @@ export const GoogleMapsComponent = React.forwardRef(); + const drawingManagerRef = useRef(); const map = useRef(); const overlayRef = useRef(); const [needUpdateFlag, forceUpdate] = useState([]); @@ -119,7 +124,7 @@ export const GoogleMapsComponent = React.forwardRef({ strokeWeight: 5, @@ -240,9 +245,8 @@ export const GoogleMapsComponent = React.forwardRef { const center = await getCurrentPosition(); map.current = new google.maps.Map(mapContainerRef.current, { diff --git a/packages/plugins/@nocobase/plugin-map/src/client/googleMapsDrawingManager.ts b/packages/plugins/@nocobase/plugin-map/src/client/googleMapsDrawingManager.ts new file mode 100644 index 00000000000..297a9902946 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-map/src/client/googleMapsDrawingManager.ts @@ -0,0 +1,307 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export type GoogleMapsDrawingMode = 'marker' | 'polyline' | 'polygon' | 'circle' | null; + +export type GoogleMapsOverlay = google.maps.Marker | google.maps.Polyline | google.maps.Polygon | google.maps.Circle; + +export interface GoogleMapsOverlayCompleteEvent { + type: Exclude; + overlay: GoogleMapsOverlay; +} + +export interface GoogleMapsDrawingManager { + setDrawingMode: (mode: GoogleMapsDrawingMode) => void; + addListener: ( + eventName: 'overlaycomplete', + listener: (event: GoogleMapsOverlayCompleteEvent) => void, + ) => google.maps.MapsEventListener; + handleClick: (event: google.maps.MapMouseEvent) => void; + handleOverlayClick: (event: google.maps.MapMouseEvent, fallbackPosition?: google.maps.LatLng) => void; + handleDoubleClick: () => void; + completeDrawing: () => void; + unbindAll: () => void; +} + +interface GoogleMapsDrawingManagerOptions { + drawingMode: GoogleMapsDrawingMode; + markerOptions: google.maps.MarkerOptions; + polygonOptions: google.maps.PolygonOptions; + polylineOptions: google.maps.PolylineOptions; + circleOptions: google.maps.CircleOptions; + map: google.maps.Map; +} + +const createMapsEventListener = (remove: () => void): google.maps.MapsEventListener => { + return { remove }; +}; + +export const createGoogleMapsDrawingManager = (options: GoogleMapsDrawingManagerOptions): GoogleMapsDrawingManager => { + const overlayCompleteListeners = new Set<(event: GoogleMapsOverlayCompleteEvent) => void>(); + let mapListeners: google.maps.MapsEventListener[] = []; + let drawingMode: GoogleMapsDrawingMode = null; + let activeOverlay: GoogleMapsOverlay = null; + let activePath: google.maps.MVCArray = null; + let circleCenter: google.maps.LatLng = null; + let clickHandler: (event: google.maps.MapMouseEvent, fallbackPosition?: google.maps.LatLng) => void = null; + let doubleClickHandler: () => void = null; + let lastClickTime = 0; + let lastClickPosition: google.maps.LatLng = null; + + const clearMapListeners = () => { + mapListeners.forEach((listener) => { + listener.remove(); + }); + mapListeners = []; + }; + + const clearActiveOverlay = () => { + activeOverlay?.setMap(null); + activeOverlay = null; + activePath = null; + circleCenter = null; + lastClickPosition = null; + lastClickTime = 0; + }; + + const resetMapDrawingOptions = () => { + options.map.setOptions({ + draggableCursor: undefined, + disableDoubleClickZoom: false, + }); + }; + + const stopDrawing = () => { + clearMapListeners(); + clearActiveOverlay(); + clickHandler = null; + doubleClickHandler = null; + resetMapDrawingOptions(); + }; + + const emitOverlayComplete = (event: GoogleMapsOverlayCompleteEvent) => { + overlayCompleteListeners.forEach((listener) => { + listener(event); + }); + }; + + const completeActiveOverlay = (type: Exclude) => { + if (!activeOverlay) { + return; + } + + if ((type === 'polygon' || type === 'polyline') && activePath) { + const path = activePath.getArray(); + const normalizedPath = path.filter((position, index) => { + const previous = path[index - 1]; + return !previous || previous.lat() !== position.lat() || previous.lng() !== position.lng(); + }); + activePath.clear(); + normalizedPath.forEach((position) => { + activePath.push(position); + }); + } + + let overlay = activeOverlay; + if (type === 'polygon' && activePath) { + activeOverlay.setMap(null); + overlay = new google.maps.Polygon({ + ...options.polygonOptions, + paths: activePath.getArray(), + map: options.map, + }); + } + + activeOverlay = null; + activePath = null; + circleCenter = null; + clearMapListeners(); + resetMapDrawingOptions(); + drawingMode = null; + emitOverlayComplete({ type, overlay }); + }; + + const getEventPosition = (event: google.maps.MapMouseEvent, fallbackPosition?: google.maps.LatLng) => { + return event.latLng || fallbackPosition || null; + }; + + const shouldSkipDuplicateClick = (position: google.maps.LatLng) => { + const now = Date.now(); + const isDuplicate = + lastClickPosition && + now - lastClickTime < 50 && + lastClickPosition.lat() === position.lat() && + lastClickPosition.lng() === position.lng(); + + lastClickTime = now; + lastClickPosition = position; + + return isDuplicate; + }; + + const startMarkerDrawing = () => { + clickHandler = (event: google.maps.MapMouseEvent, fallbackPosition?: google.maps.LatLng) => { + const position = getEventPosition(event, fallbackPosition); + if (!position || shouldSkipDuplicateClick(position)) { + return; + } + + const marker = new google.maps.Marker({ + ...options.markerOptions, + position, + map: options.map, + }); + emitOverlayComplete({ type: 'marker', overlay: marker }); + }; + mapListeners.push(options.map.addListener('click', clickHandler)); + }; + + const ensurePathOverlay = (type: 'polyline' | 'polygon') => { + if (!activePath) { + activePath = new google.maps.MVCArray(); + } + + if (!activeOverlay) { + activeOverlay = new google.maps.Polyline({ + ...(type === 'polyline' ? options.polylineOptions : options.polygonOptions), + path: activePath, + map: options.map, + }); + } + + return activePath; + }; + + const startPathDrawing = (type: 'polyline' | 'polygon') => { + options.map.setOptions({ + draggableCursor: 'crosshair', + disableDoubleClickZoom: true, + }); + + clickHandler = (event: google.maps.MapMouseEvent, fallbackPosition?: google.maps.LatLng) => { + const position = getEventPosition(event, fallbackPosition); + if (!position || shouldSkipDuplicateClick(position)) { + return; + } + ensurePathOverlay(type).push(position); + }; + mapListeners.push(options.map.addListener('click', clickHandler)); + + doubleClickHandler = () => { + const minPoints = type === 'polygon' ? 3 : 2; + if (!activePath || activePath.getLength() < minPoints) { + return; + } + + completeActiveOverlay(type); + }; + mapListeners.push(options.map.addListener('dblclick', doubleClickHandler)); + }; + + const updateCircleRadius = (position: google.maps.LatLng) => { + if (!circleCenter || !(activeOverlay instanceof google.maps.Circle)) { + return; + } + + activeOverlay.setRadius(google.maps.geometry.spherical.computeDistanceBetween(circleCenter, position)); + }; + + const startCircleDrawing = () => { + options.map.setOptions({ + draggableCursor: 'crosshair', + }); + + clickHandler = (event: google.maps.MapMouseEvent, fallbackPosition?: google.maps.LatLng) => { + const position = getEventPosition(event, fallbackPosition); + if (!position || shouldSkipDuplicateClick(position)) { + return; + } + + if (!circleCenter) { + circleCenter = position; + activeOverlay = new google.maps.Circle({ + ...options.circleOptions, + center: circleCenter, + radius: 0, + map: options.map, + }); + return; + } + + updateCircleRadius(position); + completeActiveOverlay('circle'); + }; + mapListeners.push(options.map.addListener('click', clickHandler)); + + mapListeners.push( + options.map.addListener('mousemove', (event: google.maps.MapMouseEvent) => { + const position = getEventPosition(event); + if (!position) { + return; + } + + updateCircleRadius(position); + }), + ); + }; + + const manager: GoogleMapsDrawingManager = { + setDrawingMode(mode) { + stopDrawing(); + drawingMode = mode; + + if (!drawingMode) { + return; + } + + if (drawingMode === 'marker') { + startMarkerDrawing(); + } else if (drawingMode === 'polyline' || drawingMode === 'polygon') { + startPathDrawing(drawingMode); + } else if (drawingMode === 'circle') { + startCircleDrawing(); + } + }, + addListener(eventName, listener) { + if (eventName !== 'overlaycomplete') { + return createMapsEventListener(() => {}); + } + + overlayCompleteListeners.add(listener); + return createMapsEventListener(() => { + overlayCompleteListeners.delete(listener); + }); + }, + handleClick(event) { + clickHandler?.(event); + }, + handleOverlayClick(event, fallbackPosition) { + clickHandler?.(event, fallbackPosition); + }, + handleDoubleClick() { + doubleClickHandler?.(); + }, + completeDrawing() { + const minPoints = drawingMode === 'polygon' ? 3 : 2; + if ((drawingMode === 'polygon' || drawingMode === 'polyline') && activePath?.getLength() >= minPoints) { + completeActiveOverlay(drawingMode); + } else if (drawingMode === 'circle' && activeOverlay) { + completeActiveOverlay('circle'); + } + }, + unbindAll() { + stopDrawing(); + overlayCompleteListeners.clear(); + }, + }; + + manager.setDrawingMode(options.drawingMode); + + return manager; +};