fix(plugin-map): restore v1 Google Maps selection (#9735)

This commit is contained in:
Katherine
2026-06-10 21:23:19 +08:00
committed by GitHub
parent 93780cc253
commit 74f3a30be3
3 changed files with 443 additions and 49 deletions
@@ -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) => {
// 新版 UISchema1.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<google.maps.Polygon | null>(null);
const overlaysRef = useRef<google.maps.MVCObject[]>([]);
const selectedRecordKeysRef = useRef<Set<string | number>>(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;
@@ -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<GoogleMapForwardedRefProps,
const { t } = useMapTranslation();
const { getField } = useCollection_deprecated();
const fieldSchema = useFieldSchema();
const drawingManagerRef = useRef<google.maps.drawing.DrawingManager>();
const drawingManagerRef = useRef<GoogleMapsDrawingManager>();
const map = useRef<google.maps.Map>();
const overlayRef = useRef<google.maps.Marker | google.maps.Polygon | google.maps.Polyline | google.maps.Circle>();
const [needUpdateFlag, forceUpdate] = useState([]);
@@ -119,7 +124,7 @@ export const GoogleMapsComponent = React.forwardRef<GoogleMapForwardedRefProps,
return collectionField?.interface;
}, [props?.type, fieldSchema?.name]);
const drawingMode = useRef(getDrawingMode(type) as google.maps.drawing.OverlayType);
const drawingMode = useRef(getDrawingMode(type) as GoogleMapsDrawingMode);
const [commonOptions] = useState<OverlayOptions>({
strokeWeight: 5,
@@ -240,9 +245,8 @@ export const GoogleMapsComponent = React.forwardRef<GoogleMapForwardedRefProps,
...additionalOptions,
map: map.current,
};
drawingManagerRef.current = new google.maps.drawing.DrawingManager({
drawingManagerRef.current = createGoogleMapsDrawingManager({
drawingMode: drawingMode.current,
drawingControl: false,
markerOptions: { ...currentOptions, icon: getIcon(defaultImage) },
polygonOptions: currentOptions,
polylineOptions: currentOptions,
@@ -329,7 +333,12 @@ export const GoogleMapsComponent = React.forwardRef<GoogleMapForwardedRefProps,
error(err, ...args);
};
Promise.all([loader.importLibrary('drawing'), loader.importLibrary('core'), loader.importLibrary('geometry')])
Promise.all([
loader.importLibrary('maps'),
loader.importLibrary('core'),
loader.importLibrary('geometry'),
loader.importLibrary('marker'),
])
.then(async (res) => {
const center = await getCurrentPosition();
map.current = new google.maps.Map(mapContainerRef.current, {
@@ -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<GoogleMapsDrawingMode, null>;
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<google.maps.LatLng> = 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<GoogleMapsDrawingMode, null>) => {
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<google.maps.LatLng>();
}
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;
};