1422 lines
48 KiB
TypeScript
1422 lines
48 KiB
TypeScript
/**
|
||
* MapShapeUtil - Mapus-inspired collaborative map shape for tldraw
|
||
*
|
||
* Inspired by https://github.com/alyssaxuu/mapus
|
||
*
|
||
* Features:
|
||
* - Real-time collaboration with user cursors/presence
|
||
* - Find Nearby places (restaurants, hotels, etc.)
|
||
* - Drawing tools (markers, lines, areas)
|
||
* - Annotations list with visibility toggle
|
||
* - Color picker for annotations
|
||
* - Search and routing (Nominatim + OSRM)
|
||
* - GPS location sharing
|
||
* - "Observe" mode to follow other users
|
||
*/
|
||
|
||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLResizeInfo, resizeBox, T } from 'tldraw';
|
||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||
import maplibregl from 'maplibre-gl';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper';
|
||
import { usePinnedToView } from '../hooks/usePinnedToView';
|
||
import { useMaximize } from '../hooks/useMaximize';
|
||
|
||
// =============================================================================
|
||
// Types
|
||
// =============================================================================
|
||
|
||
export interface Coordinate {
|
||
lat: number;
|
||
lng: number;
|
||
}
|
||
|
||
export interface MapViewport {
|
||
center: Coordinate;
|
||
zoom: number;
|
||
bearing: number;
|
||
pitch: number;
|
||
}
|
||
|
||
export interface Annotation {
|
||
id: string;
|
||
type: 'marker' | 'line' | 'area';
|
||
name: string;
|
||
color: string;
|
||
visible: boolean;
|
||
coordinates: Coordinate[]; // Single for marker, multiple for line/area
|
||
createdBy?: string;
|
||
createdAt: number;
|
||
}
|
||
|
||
export interface RouteInfo {
|
||
distance: number;
|
||
duration: number;
|
||
geometry: GeoJSON.LineString;
|
||
}
|
||
|
||
export interface CollaboratorPresence {
|
||
id: string;
|
||
name: string;
|
||
color: string;
|
||
cursor?: Coordinate;
|
||
location?: Coordinate;
|
||
isObserving?: string; // ID of user being observed
|
||
lastSeen: number;
|
||
}
|
||
|
||
export type IMapShape = TLBaseShape<
|
||
'Map',
|
||
{
|
||
w: number;
|
||
h: number;
|
||
viewport: MapViewport;
|
||
styleKey: string;
|
||
title: string;
|
||
description: string;
|
||
annotations: Annotation[];
|
||
route: RouteInfo | null;
|
||
waypoints: Coordinate[];
|
||
collaborators: CollaboratorPresence[];
|
||
showSidebar: boolean;
|
||
pinnedToView: boolean;
|
||
tags: string[];
|
||
isMinimized: boolean;
|
||
// Legacy compatibility properties
|
||
interactive: boolean;
|
||
showGPS: boolean;
|
||
showSearch: boolean;
|
||
showDirections: boolean;
|
||
sharingLocation: boolean;
|
||
gpsUsers: CollaboratorPresence[];
|
||
}
|
||
>;
|
||
|
||
// =============================================================================
|
||
// Constants
|
||
// =============================================================================
|
||
|
||
const DEFAULT_VIEWPORT: MapViewport = {
|
||
center: { lat: 40.7128, lng: -74.006 },
|
||
zoom: 12,
|
||
bearing: 0,
|
||
pitch: 0,
|
||
};
|
||
|
||
const OSRM_BASE_URL = 'https://routing.jeffemmett.com';
|
||
|
||
// Mapus color palette
|
||
const COLORS = [
|
||
'#E15F59', // Red
|
||
'#F29F51', // Orange
|
||
'#F9D458', // Yellow
|
||
'#5EBE86', // Green
|
||
'#4890E8', // Blue
|
||
'#634FF1', // Purple
|
||
'#A564D2', // Violet
|
||
'#222222', // Black
|
||
];
|
||
|
||
// Find Nearby categories (inspired by Mapus)
|
||
const NEARBY_CATEGORIES = [
|
||
{ key: 'restaurant', label: 'Food', icon: '🍽️', color: '#4890E8', types: 'restaurant,cafe,fast_food,food_court' },
|
||
{ key: 'bar', label: 'Drinks', icon: '🍺', color: '#F9D458', types: 'bar,pub,cafe,wine_bar' },
|
||
{ key: 'supermarket', label: 'Groceries', icon: '🛒', color: '#5EBE86', types: 'supermarket,convenience,grocery' },
|
||
{ key: 'hotel', label: 'Hotels', icon: '🏨', color: '#AC6C48', types: 'hotel,hostel,motel,guest_house' },
|
||
{ key: 'hospital', label: 'Health', icon: '🏥', color: '#E15F59', types: 'hospital,pharmacy,clinic,doctors' },
|
||
{ key: 'bank', label: 'Services', icon: '🏦', color: '#634FF1', types: 'bank,atm,post_office' },
|
||
{ key: 'shopping', label: 'Shopping', icon: '🛍️', color: '#A564D2', types: 'mall,department_store,clothes' },
|
||
{ key: 'transport', label: 'Transport', icon: '🚉', color: '#718390', types: 'bus_station,train_station,subway' },
|
||
];
|
||
|
||
// Map styles
|
||
const MAP_STYLES = {
|
||
voyager: {
|
||
name: 'Voyager',
|
||
url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
||
icon: '🗺️',
|
||
},
|
||
positron: {
|
||
name: 'Light',
|
||
url: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||
icon: '☀️',
|
||
},
|
||
darkMatter: {
|
||
name: 'Dark',
|
||
url: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||
icon: '🌙',
|
||
},
|
||
satellite: {
|
||
name: 'Satellite',
|
||
url: {
|
||
version: 8,
|
||
sources: {
|
||
'esri-satellite': {
|
||
type: 'raster',
|
||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||
tileSize: 256,
|
||
attribution: '© Esri',
|
||
maxzoom: 19,
|
||
},
|
||
},
|
||
layers: [{ id: 'satellite-layer', type: 'raster', source: 'esri-satellite' }],
|
||
} as maplibregl.StyleSpecification,
|
||
icon: '🛰️',
|
||
},
|
||
} as const;
|
||
|
||
type StyleKey = keyof typeof MAP_STYLES;
|
||
|
||
// =============================================================================
|
||
// Shape Definition
|
||
// =============================================================================
|
||
|
||
export class MapShape extends BaseBoxShapeUtil<IMapShape> {
|
||
static override type = 'Map' as const;
|
||
|
||
// Map theme color: Blue (consistent with mapping/navigation)
|
||
static readonly PRIMARY_COLOR = '#4890E8';
|
||
|
||
static override props = {
|
||
w: T.number,
|
||
h: T.number,
|
||
viewport: T.any,
|
||
styleKey: T.string,
|
||
title: T.string,
|
||
description: T.string,
|
||
annotations: T.any,
|
||
route: T.any,
|
||
waypoints: T.any,
|
||
collaborators: T.any,
|
||
showSidebar: T.boolean,
|
||
pinnedToView: T.boolean,
|
||
tags: T.any,
|
||
isMinimized: T.boolean,
|
||
// Legacy compatibility properties
|
||
interactive: T.boolean,
|
||
showGPS: T.boolean,
|
||
showSearch: T.boolean,
|
||
showDirections: T.boolean,
|
||
sharingLocation: T.boolean,
|
||
gpsUsers: T.any,
|
||
};
|
||
|
||
getDefaultProps(): IMapShape['props'] {
|
||
return {
|
||
w: 800,
|
||
h: 550,
|
||
viewport: DEFAULT_VIEWPORT,
|
||
styleKey: 'voyager',
|
||
title: 'Collaborative Map',
|
||
description: 'Click to explore together',
|
||
annotations: [],
|
||
route: null,
|
||
waypoints: [],
|
||
collaborators: [],
|
||
showSidebar: true,
|
||
pinnedToView: false,
|
||
tags: ['map'],
|
||
isMinimized: false,
|
||
// Legacy compatibility defaults
|
||
interactive: true,
|
||
showGPS: false,
|
||
showSearch: false,
|
||
showDirections: false,
|
||
sharingLocation: false,
|
||
gpsUsers: [],
|
||
};
|
||
}
|
||
|
||
override canResize() { return true; }
|
||
override canEdit() { return false; }
|
||
|
||
override onResize(shape: IMapShape, info: TLResizeInfo<IMapShape>) {
|
||
return resizeBox(shape, info);
|
||
}
|
||
|
||
indicator(shape: IMapShape) {
|
||
const height = shape.props.isMinimized ? 40 : shape.props.h + 40;
|
||
return <rect x={0} y={0} width={shape.props.w} height={height} fill="none" rx={8} />;
|
||
}
|
||
|
||
component(shape: IMapShape) {
|
||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id);
|
||
return <MapComponent shape={shape} editor={this.editor} isSelected={isSelected} />;
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Styles
|
||
// =============================================================================
|
||
|
||
const styles = {
|
||
sidebar: {
|
||
width: 280,
|
||
background: '#fff',
|
||
borderRight: '1px solid #E8E8E8',
|
||
height: '100%',
|
||
overflowY: 'auto' as const,
|
||
fontSize: 13,
|
||
zIndex: 10,
|
||
position: 'relative' as const,
|
||
},
|
||
section: {
|
||
padding: '14px 16px',
|
||
borderBottom: '1px solid #E8E8E8',
|
||
},
|
||
sectionTitle: {
|
||
fontWeight: 600,
|
||
fontSize: 13,
|
||
color: '#222',
|
||
marginBottom: 10,
|
||
},
|
||
button: {
|
||
border: 'none',
|
||
borderRadius: 6,
|
||
background: '#fff',
|
||
cursor: 'pointer',
|
||
transition: 'background 0.15s',
|
||
pointerEvents: 'auto' as const,
|
||
},
|
||
toolbar: {
|
||
position: 'absolute' as const,
|
||
bottom: 20,
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
background: '#fff',
|
||
borderRadius: 12,
|
||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||
display: 'flex',
|
||
padding: 6,
|
||
gap: 4,
|
||
zIndex: 10000,
|
||
pointerEvents: 'auto' as const,
|
||
},
|
||
toolButton: {
|
||
width: 42,
|
||
height: 42,
|
||
border: 'none',
|
||
borderRadius: 8,
|
||
background: 'transparent',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 18,
|
||
transition: 'background 0.15s',
|
||
pointerEvents: 'auto' as const,
|
||
},
|
||
activeToolButton: {
|
||
background: '#222',
|
||
color: '#fff',
|
||
},
|
||
mapButton: {
|
||
pointerEvents: 'auto' as const,
|
||
zIndex: 10000,
|
||
},
|
||
};
|
||
|
||
// =============================================================================
|
||
// Map Component
|
||
// =============================================================================
|
||
|
||
function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor: MapShape['editor']; isSelected: boolean }) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
|
||
const isMountedRef = useRef(true); // Track if component is still mounted
|
||
|
||
const [isLoaded, setIsLoaded] = useState(false);
|
||
const [activeTool, setActiveTool] = useState<'cursor' | 'marker' | 'line' | 'area' | 'eraser'>('cursor');
|
||
const activeToolRef = useRef(activeTool); // Ref to track current tool in event handlers
|
||
const [selectedColor, setSelectedColor] = useState(COLORS[4]);
|
||
|
||
// Keep ref in sync with state
|
||
useEffect(() => {
|
||
activeToolRef.current = activeTool;
|
||
}, [activeTool]);
|
||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||
const [_nearbyPlaces, setNearbyPlaces] = useState<any[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [isFetchingNearby, setIsFetchingNearby] = useState(false);
|
||
const [observingUser, setObservingUser] = useState<string | null>(null);
|
||
|
||
const styleKey = (shape.props.styleKey || 'voyager') as StyleKey;
|
||
const currentStyle = MAP_STYLES[styleKey] || MAP_STYLES.voyager;
|
||
|
||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||
usePinnedToView(editor, shape.id, shape.props.pinnedToView);
|
||
|
||
// Use the maximize hook for fullscreen functionality
|
||
const { isMaximized, toggleMaximize } = useMaximize({
|
||
editor: editor,
|
||
shapeId: shape.id,
|
||
currentW: shape.props.w,
|
||
currentH: shape.props.h,
|
||
shapeType: 'Map',
|
||
});
|
||
|
||
// Track mounted state for cleanup
|
||
useEffect(() => {
|
||
isMountedRef.current = true;
|
||
return () => {
|
||
isMountedRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
// ==========================================================================
|
||
// Map Initialization
|
||
// ==========================================================================
|
||
|
||
useEffect(() => {
|
||
if (!containerRef.current) return;
|
||
|
||
const map = new maplibregl.Map({
|
||
container: containerRef.current,
|
||
style: typeof currentStyle.url === 'string' ? currentStyle.url : currentStyle.url,
|
||
center: [shape.props.viewport.center.lng, shape.props.viewport.center.lat],
|
||
zoom: shape.props.viewport.zoom,
|
||
bearing: shape.props.viewport.bearing,
|
||
pitch: shape.props.viewport.pitch,
|
||
attributionControl: false,
|
||
});
|
||
|
||
mapRef.current = map;
|
||
|
||
const handleLoad = () => {
|
||
if (isMountedRef.current) {
|
||
setIsLoaded(true);
|
||
}
|
||
};
|
||
|
||
// Save viewport changes with null checks
|
||
const handleMoveEnd = () => {
|
||
if (!isMountedRef.current || !mapRef.current) return;
|
||
try {
|
||
const center = mapRef.current.getCenter();
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: {
|
||
viewport: {
|
||
center: { lat: center.lat, lng: center.lng },
|
||
zoom: mapRef.current.getZoom(),
|
||
bearing: mapRef.current.getBearing(),
|
||
pitch: mapRef.current.getPitch(),
|
||
},
|
||
},
|
||
});
|
||
} catch (err) {
|
||
// Map may have been destroyed, ignore
|
||
}
|
||
};
|
||
|
||
// Handle map clicks based on active tool
|
||
const handleClick = (e: maplibregl.MapMouseEvent) => {
|
||
if (!isMountedRef.current) return;
|
||
const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng };
|
||
const currentTool = activeToolRef.current;
|
||
|
||
console.log('Map click with tool:', currentTool, 'at', coord);
|
||
|
||
if (currentTool === 'marker') {
|
||
addAnnotation('marker', [coord]);
|
||
}
|
||
// TODO: Implement line and area drawing
|
||
};
|
||
|
||
map.on('load', handleLoad);
|
||
map.on('moveend', handleMoveEnd);
|
||
map.on('click', handleClick);
|
||
|
||
return () => {
|
||
// Remove event listeners before destroying map
|
||
map.off('load', handleLoad);
|
||
map.off('moveend', handleMoveEnd);
|
||
map.off('click', handleClick);
|
||
|
||
// Clear all markers
|
||
markersRef.current.forEach((marker) => {
|
||
try {
|
||
marker.remove();
|
||
} catch (err) {
|
||
// Marker may already be removed
|
||
}
|
||
});
|
||
markersRef.current.clear();
|
||
|
||
// Destroy the map
|
||
try {
|
||
map.remove();
|
||
} catch (err) {
|
||
// Map may already be destroyed
|
||
}
|
||
mapRef.current = null;
|
||
setIsLoaded(false);
|
||
};
|
||
}, [containerRef.current]);
|
||
|
||
// Style changes
|
||
useEffect(() => {
|
||
if (!mapRef.current || !isLoaded || !isMountedRef.current) return;
|
||
try {
|
||
mapRef.current.setStyle(typeof currentStyle.url === 'string' ? currentStyle.url : currentStyle.url);
|
||
} catch (err) {
|
||
// Map may have been destroyed
|
||
}
|
||
}, [styleKey, isLoaded]);
|
||
|
||
// Resize
|
||
useEffect(() => {
|
||
if (!mapRef.current || !isLoaded || !isMountedRef.current) return;
|
||
const resizeTimeout = setTimeout(() => {
|
||
if (mapRef.current && isMountedRef.current) {
|
||
try {
|
||
mapRef.current.resize();
|
||
} catch (err) {
|
||
// Map may have been destroyed
|
||
}
|
||
}
|
||
}, 0);
|
||
return () => clearTimeout(resizeTimeout);
|
||
}, [shape.props.w, shape.props.h, isLoaded, shape.props.showSidebar]);
|
||
|
||
// ==========================================================================
|
||
// Annotations
|
||
// ==========================================================================
|
||
|
||
useEffect(() => {
|
||
if (!mapRef.current || !isLoaded || !isMountedRef.current) return;
|
||
|
||
const map = mapRef.current;
|
||
const currentIds = new Set(shape.props.annotations.map((a: Annotation) => a.id));
|
||
|
||
// Remove old markers
|
||
markersRef.current.forEach((marker, id) => {
|
||
if (!currentIds.has(id)) {
|
||
try {
|
||
marker.remove();
|
||
} catch (err) {
|
||
// Marker may already be removed
|
||
}
|
||
markersRef.current.delete(id);
|
||
}
|
||
});
|
||
|
||
// Add/update markers
|
||
shape.props.annotations.forEach((ann: Annotation) => {
|
||
if (!isMountedRef.current || !mapRef.current) return;
|
||
if (ann.type !== 'marker' || !ann.visible) return;
|
||
|
||
let marker = markersRef.current.get(ann.id);
|
||
const coord = ann.coordinates[0];
|
||
|
||
if (!marker && coord) {
|
||
try {
|
||
const el = document.createElement('div');
|
||
el.className = 'map-annotation-marker';
|
||
el.style.cssText = `
|
||
width: 32px;
|
||
height: 32px;
|
||
background: ${ann.color};
|
||
border: 3px solid white;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
`;
|
||
el.textContent = '📍';
|
||
el.title = ann.name;
|
||
|
||
const popup = new maplibregl.Popup({ offset: 20 })
|
||
.setHTML(`<div style="padding: 8px; font-size: 13px;"><strong>${ann.name}</strong></div>`);
|
||
|
||
marker = new maplibregl.Marker({ element: el, draggable: true })
|
||
.setLngLat([coord.lng, coord.lat])
|
||
.setPopup(popup)
|
||
.addTo(map);
|
||
|
||
marker.on('dragend', () => {
|
||
if (!isMountedRef.current || !marker) return;
|
||
try {
|
||
const lngLat = marker.getLngLat();
|
||
updateAnnotationPosition(ann.id, [{ lat: lngLat.lat, lng: lngLat.lng }]);
|
||
} catch (err) {
|
||
// Marker may have been removed
|
||
}
|
||
});
|
||
|
||
markersRef.current.set(ann.id, marker);
|
||
} catch (err) {
|
||
// Map may have been destroyed
|
||
}
|
||
} else if (marker && coord) {
|
||
try {
|
||
marker.setLngLat([coord.lng, coord.lat]);
|
||
} catch (err) {
|
||
// Marker may have been removed
|
||
}
|
||
}
|
||
});
|
||
|
||
// Hide markers for invisible annotations
|
||
markersRef.current.forEach((marker, id) => {
|
||
const ann = shape.props.annotations.find((a: Annotation) => a.id === id);
|
||
if (ann && !ann.visible) {
|
||
try {
|
||
marker.remove();
|
||
} catch (err) {
|
||
// Marker may already be removed
|
||
}
|
||
markersRef.current.delete(id);
|
||
}
|
||
});
|
||
}, [shape.props.annotations, isLoaded]);
|
||
|
||
// ==========================================================================
|
||
// Collaborator presence (cursors/locations)
|
||
// ==========================================================================
|
||
|
||
useEffect(() => {
|
||
if (!mapRef.current || !isLoaded || !isMountedRef.current) return;
|
||
|
||
// TODO: Render collaborator cursors on map
|
||
// This would be integrated with tldraw's presence system
|
||
}, [shape.props.collaborators, isLoaded]);
|
||
|
||
// ==========================================================================
|
||
// Actions
|
||
// ==========================================================================
|
||
|
||
const addAnnotation = useCallback((type: Annotation['type'], coordinates: Coordinate[], options?: { name?: string; color?: string }) => {
|
||
const newAnnotation: Annotation = {
|
||
id: `ann-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
type,
|
||
name: options?.name || `${type.charAt(0).toUpperCase() + type.slice(1)} ${shape.props.annotations.length + 1}`,
|
||
color: options?.color || selectedColor,
|
||
visible: true,
|
||
coordinates,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { annotations: [...shape.props.annotations, newAnnotation] },
|
||
});
|
||
}, [shape.props.annotations, selectedColor, shape.id, editor]);
|
||
|
||
const updateAnnotationPosition = useCallback((annotationId: string, coordinates: Coordinate[]) => {
|
||
const updated = shape.props.annotations.map((ann: Annotation) =>
|
||
ann.id === annotationId ? { ...ann, coordinates } : ann
|
||
);
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { annotations: updated },
|
||
});
|
||
}, [shape.props.annotations, shape.id, editor]);
|
||
|
||
const toggleAnnotationVisibility = useCallback((annotationId: string) => {
|
||
const updated = shape.props.annotations.map((ann: Annotation) =>
|
||
ann.id === annotationId ? { ...ann, visible: !ann.visible } : ann
|
||
);
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { annotations: updated },
|
||
});
|
||
}, [shape.props.annotations, shape.id, editor]);
|
||
|
||
const removeAnnotation = useCallback((annotationId: string) => {
|
||
const updated = shape.props.annotations.filter((ann: Annotation) => ann.id !== annotationId);
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { annotations: updated },
|
||
});
|
||
}, [shape.props.annotations, shape.id, editor]);
|
||
|
||
const hideAllAnnotations = useCallback(() => {
|
||
const updated = shape.props.annotations.map((ann: Annotation) => ({ ...ann, visible: false }));
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { annotations: updated },
|
||
});
|
||
}, [shape.props.annotations, shape.id, editor]);
|
||
|
||
// ==========================================================================
|
||
// Search
|
||
// ==========================================================================
|
||
|
||
const searchPlaces = useCallback(async () => {
|
||
if (!searchQuery.trim()) return;
|
||
setIsSearching(true);
|
||
try {
|
||
const response = await fetch(
|
||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=6`,
|
||
{ headers: { 'User-Agent': 'CanvasWebsite/1.0' } }
|
||
);
|
||
const data = await response.json() as { display_name: string; lat: string; lon: string }[];
|
||
setSearchResults(data.map((r) => ({
|
||
name: r.display_name,
|
||
lat: parseFloat(r.lat),
|
||
lng: parseFloat(r.lon),
|
||
})));
|
||
} catch (err) {
|
||
console.error('Search error:', err);
|
||
} finally {
|
||
setIsSearching(false);
|
||
}
|
||
}, [searchQuery]);
|
||
|
||
const selectSearchResult = useCallback((result: { lat: number; lng: number; name: string }) => {
|
||
if (mapRef.current && isMountedRef.current) {
|
||
try {
|
||
mapRef.current.flyTo({ center: [result.lng, result.lat], zoom: 15, duration: 1000 });
|
||
} catch (err) {
|
||
// Map may have been destroyed
|
||
}
|
||
}
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
}, []);
|
||
|
||
// ==========================================================================
|
||
// Find Nearby
|
||
// ==========================================================================
|
||
|
||
const findNearby = useCallback(async (category: typeof NEARBY_CATEGORIES[0]) => {
|
||
if (!mapRef.current || !isMountedRef.current) return;
|
||
|
||
console.log('🗺️ findNearby called for category:', category.label);
|
||
setIsFetchingNearby(true);
|
||
|
||
let bounds;
|
||
try {
|
||
bounds = mapRef.current.getBounds();
|
||
console.log('🗺️ Map bounds:', bounds.toString());
|
||
} catch (err) {
|
||
console.error('🗺️ Error getting bounds:', err);
|
||
setIsFetchingNearby(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const query = `
|
||
[out:json][timeout:10];
|
||
(
|
||
node["amenity"~"${category.types.replace(/,/g, '|')}"](${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()});
|
||
);
|
||
out body 10;
|
||
`;
|
||
console.log('🗺️ Overpass query:', query);
|
||
|
||
const response = await fetch('https://overpass-api.de/api/interpreter', {
|
||
method: 'POST',
|
||
body: query,
|
||
});
|
||
|
||
if (!isMountedRef.current) {
|
||
setIsFetchingNearby(false);
|
||
return;
|
||
}
|
||
|
||
console.log('🗺️ Overpass response status:', response.status);
|
||
const data = await response.json() as { elements: { id: number; lat: number; lon: number; tags?: { name?: string; amenity?: string } }[] };
|
||
console.log('🗺️ Found', data.elements.length, 'places');
|
||
|
||
const places = data.elements.slice(0, 10).map((el) => ({
|
||
id: el.id,
|
||
name: el.tags?.name || category.label,
|
||
lat: el.lat,
|
||
lng: el.lon,
|
||
type: el.tags?.amenity || category.key,
|
||
color: category.color,
|
||
}));
|
||
|
||
if (!isMountedRef.current) {
|
||
setIsFetchingNearby(false);
|
||
return;
|
||
}
|
||
setNearbyPlaces(places);
|
||
|
||
// Add markers for nearby places
|
||
console.log('🗺️ Adding', places.length, 'markers');
|
||
places.forEach((place: any) => {
|
||
if (isMountedRef.current) {
|
||
addAnnotation('marker', [{ lat: place.lat, lng: place.lng }], {
|
||
name: place.name,
|
||
color: place.color,
|
||
});
|
||
}
|
||
});
|
||
setIsFetchingNearby(false);
|
||
} catch (err) {
|
||
console.error('🗺️ Find nearby error:', err);
|
||
setIsFetchingNearby(false);
|
||
}
|
||
}, [addAnnotation]);
|
||
|
||
// ==========================================================================
|
||
// Observe User
|
||
// ==========================================================================
|
||
|
||
const observeUser = useCallback((userId: string | null) => {
|
||
setObservingUser(userId);
|
||
if (userId && mapRef.current && isMountedRef.current) {
|
||
const collaborator = shape.props.collaborators.find((c: CollaboratorPresence) => c.id === userId);
|
||
if (collaborator?.location) {
|
||
try {
|
||
mapRef.current.flyTo({
|
||
center: [collaborator.location.lng, collaborator.location.lat],
|
||
zoom: 15,
|
||
duration: 1000,
|
||
});
|
||
} catch (err) {
|
||
// Map may have been destroyed
|
||
}
|
||
}
|
||
}
|
||
}, [shape.props.collaborators]);
|
||
|
||
// ==========================================================================
|
||
// Title/Description
|
||
// ==========================================================================
|
||
|
||
const updateTitle = useCallback((title: string) => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { title },
|
||
});
|
||
}, [shape.id, editor]);
|
||
|
||
const updateDescription = useCallback((description: string) => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { description },
|
||
});
|
||
}, [shape.id, editor]);
|
||
|
||
// ==========================================================================
|
||
// Toggle Sidebar
|
||
// ==========================================================================
|
||
|
||
const toggleSidebar = useCallback(() => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { showSidebar: !shape.props.showSidebar },
|
||
});
|
||
}, [shape.id, shape.props.showSidebar, editor]);
|
||
|
||
// ==========================================================================
|
||
// Style Change
|
||
// ==========================================================================
|
||
|
||
const changeStyle = useCallback((key: StyleKey) => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { styleKey: key },
|
||
});
|
||
}, [shape.id, editor]);
|
||
|
||
// ==========================================================================
|
||
// Event Handlers
|
||
// ==========================================================================
|
||
|
||
const stopPropagation = useCallback((e: { stopPropagation: () => void }) => {
|
||
e.stopPropagation();
|
||
}, []);
|
||
|
||
// Handle wheel events on map container - attach native listener for proper capture
|
||
useEffect(() => {
|
||
const mapContainer = containerRef.current?.parentElement;
|
||
if (!mapContainer) return;
|
||
|
||
const handleWheel = (e: WheelEvent) => {
|
||
// Stop propagation to prevent tldraw from capturing the wheel event
|
||
e.stopPropagation();
|
||
// Let maplibre handle the wheel event natively for zooming
|
||
// Don't prevent default - let the map's scrollZoom handle it
|
||
};
|
||
|
||
// Capture wheel events before they bubble up to tldraw
|
||
mapContainer.addEventListener('wheel', handleWheel, { passive: true });
|
||
|
||
return () => {
|
||
mapContainer.removeEventListener('wheel', handleWheel);
|
||
};
|
||
}, [isLoaded]);
|
||
|
||
// Close handler for StandardizedToolWrapper
|
||
const handleClose = useCallback(() => {
|
||
editor.deleteShape(shape.id);
|
||
}, [editor, shape.id]);
|
||
|
||
// Minimize handler
|
||
const handleMinimize = useCallback(() => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { isMinimized: !shape.props.isMinimized },
|
||
});
|
||
}, [editor, shape.id, shape.props.isMinimized]);
|
||
|
||
// Pin handler
|
||
const handlePinToggle = useCallback(() => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { pinnedToView: !shape.props.pinnedToView },
|
||
});
|
||
}, [editor, shape.id, shape.props.pinnedToView]);
|
||
|
||
// Tags handler
|
||
const handleTagsChange = useCallback((newTags: string[]) => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { tags: newTags },
|
||
});
|
||
}, [editor, shape.id]);
|
||
|
||
// ==========================================================================
|
||
// Render
|
||
// ==========================================================================
|
||
|
||
const contentHeight = shape.props.h;
|
||
|
||
return (
|
||
<HTMLContainer style={{ width: shape.props.w, height: contentHeight + 40 }}>
|
||
<style>{`
|
||
.mapus-sidebar::-webkit-scrollbar { width: 6px; }
|
||
.mapus-sidebar::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
|
||
.mapus-btn:hover { background: #f7f7f7 !important; }
|
||
.mapus-btn:active { transform: scale(0.97); }
|
||
.mapus-category:hover { background: #f7f7f7 !important; }
|
||
.mapus-annotation:hover { background: #f7f7f7; }
|
||
.mapus-result:hover { background: #f3f4f6 !important; }
|
||
.mapus-tool:hover { background: #f7f7f7; }
|
||
.mapus-tool.active { background: #222 !important; color: #fff !important; }
|
||
.mapus-color:hover { transform: scale(1.15); }
|
||
.mapus-color.selected { transform: scale(1.2); box-shadow: 0 0 0 2px #222; }
|
||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||
`}</style>
|
||
|
||
<StandardizedToolWrapper
|
||
title={shape.props.title || 'Collaborative Map'}
|
||
primaryColor={MapShape.PRIMARY_COLOR}
|
||
isSelected={isSelected}
|
||
width={shape.props.w}
|
||
height={contentHeight + 40}
|
||
onClose={handleClose}
|
||
onMinimize={handleMinimize}
|
||
isMinimized={shape.props.isMinimized}
|
||
onMaximize={toggleMaximize}
|
||
isMaximized={isMaximized}
|
||
editor={editor}
|
||
shapeId={shape.id}
|
||
isPinnedToView={shape.props.pinnedToView}
|
||
onPinToggle={handlePinToggle}
|
||
tags={shape.props.tags || ['map']}
|
||
onTagsChange={handleTagsChange}
|
||
tagsEditable={true}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
position: 'relative',
|
||
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||
}}
|
||
>
|
||
{/* Left Sidebar */}
|
||
{shape.props.showSidebar && (
|
||
<div
|
||
className="mapus-sidebar"
|
||
style={styles.sidebar}
|
||
onPointerDown={stopPropagation}
|
||
>
|
||
{/* Search */}
|
||
<div style={styles.section}>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter') searchPlaces();
|
||
}}
|
||
onPointerDown={stopPropagation}
|
||
placeholder="Search for a place..."
|
||
style={{
|
||
flex: 1,
|
||
padding: '10px 12px',
|
||
border: '1px solid #E8E8E8',
|
||
borderRadius: 6,
|
||
fontSize: 13,
|
||
outline: 'none',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={searchPlaces}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
...styles.button,
|
||
padding: '10px 14px',
|
||
border: '1px solid #E8E8E8',
|
||
}}
|
||
>
|
||
{isSearching ? '⏳' : '🔍'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search Results */}
|
||
{searchResults.length > 0 && (
|
||
<div style={{ marginTop: 8, maxHeight: 200, overflowY: 'auto' }}>
|
||
{searchResults.map((result, i) => (
|
||
<div
|
||
key={i}
|
||
className="mapus-result"
|
||
onClick={() => selectSearchResult(result)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
padding: '10px 8px',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
borderRadius: 4,
|
||
}}
|
||
>
|
||
📍 {result.name.slice(0, 50)}...
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Find Nearby */}
|
||
<div style={styles.section}>
|
||
<div style={styles.sectionTitle}>
|
||
Find nearby {isFetchingNearby && <span style={{ marginLeft: 8, fontSize: 12 }}>⏳</span>}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, opacity: isFetchingNearby ? 0.5 : 1 }}>
|
||
{NEARBY_CATEGORIES.map((cat) => (
|
||
<div
|
||
key={cat.key}
|
||
className="mapus-category"
|
||
onClick={() => !isFetchingNearby && findNearby(cat)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
textAlign: 'center',
|
||
padding: '10px 4px',
|
||
borderRadius: 6,
|
||
cursor: isFetchingNearby ? 'wait' : 'pointer',
|
||
fontSize: 11,
|
||
color: '#626C72',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 22, marginBottom: 4 }}>{cat.icon}</div>
|
||
{cat.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Collaborators */}
|
||
{shape.props.collaborators.length > 0 && (
|
||
<div style={styles.section}>
|
||
<div style={styles.sectionTitle}>People ({shape.props.collaborators.length})</div>
|
||
{shape.props.collaborators.map((collab: CollaboratorPresence) => (
|
||
<div
|
||
key={collab.id}
|
||
className="mapus-annotation"
|
||
onClick={() => observeUser(observingUser === collab.id ? null : collab.id)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
padding: '8px 6px',
|
||
borderRadius: 6,
|
||
cursor: 'pointer',
|
||
background: observingUser === collab.id ? '#f0fdf4' : 'transparent',
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
background: collab.color,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 12,
|
||
color: 'white',
|
||
fontWeight: 600,
|
||
}}>
|
||
{collab.name[0].toUpperCase()}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500 }}>{collab.name}</div>
|
||
{observingUser === collab.id && (
|
||
<div style={{ fontSize: 11, color: '#22c55e' }}>👁️ Observing</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Annotations */}
|
||
<div style={styles.section}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||
<div style={styles.sectionTitle}>Annotations</div>
|
||
<button
|
||
onClick={hideAllAnnotations}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{ ...styles.button, fontSize: 12, padding: '4px 8px', color: '#626C72' }}
|
||
>
|
||
Hide all
|
||
</button>
|
||
</div>
|
||
|
||
{shape.props.annotations.length === 0 ? (
|
||
<div style={{ fontSize: 12, color: '#9ca3af', textAlign: 'center', padding: 16 }}>
|
||
No annotations yet.<br />Use the tools below to add some!
|
||
</div>
|
||
) : (
|
||
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
|
||
{shape.props.annotations.map((ann: Annotation) => (
|
||
<div
|
||
key={ann.id}
|
||
className="mapus-annotation"
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '8px 6px',
|
||
borderRadius: 6,
|
||
cursor: 'pointer',
|
||
opacity: ann.visible ? 1 : 0.5,
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: '50%',
|
||
background: ann.color,
|
||
}} />
|
||
<div style={{ flex: 1, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{ann.name}
|
||
</div>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{ ...styles.button, padding: 4, fontSize: 14 }}
|
||
>
|
||
{ann.visible ? '👁️' : '👁️🗨️'}
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{ ...styles.button, padding: 4, fontSize: 14, color: '#E15F59' }}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Attribution */}
|
||
<div style={{ ...styles.section, borderBottom: 'none', fontSize: 11, color: '#9ca3af' }}>
|
||
© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener" style={{ color: '#9ca3af' }}>OpenStreetMap</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Map Container */}
|
||
<div
|
||
style={{ flex: 1, position: 'relative', pointerEvents: 'auto' }}
|
||
onPointerDown={stopPropagation}
|
||
>
|
||
<div ref={containerRef} style={{ width: '100%', height: '100%', pointerEvents: 'auto' }} />
|
||
|
||
{/* Sidebar Toggle */}
|
||
<button
|
||
onClick={toggleSidebar}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
position: 'absolute',
|
||
top: 10,
|
||
left: 10,
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 18,
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
{shape.props.showSidebar ? '◀' : '▶'}
|
||
</button>
|
||
|
||
{/* Style Picker */}
|
||
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
|
||
<select
|
||
value={styleKey}
|
||
onChange={(e) => changeStyle(e.target.value as StyleKey)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
padding: '8px 12px',
|
||
borderRadius: 6,
|
||
border: 'none',
|
||
background: '#fff',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{Object.entries(MAP_STYLES).map(([key, style]) => (
|
||
<option key={key} value={key}>{style.icon} {style.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Zoom Controls */}
|
||
<div style={{ position: 'absolute', bottom: 80, right: 10, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}>
|
||
<button
|
||
onClick={() => mapRef.current?.zoomIn()}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 18,
|
||
}}
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => mapRef.current?.zoomOut()}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 18,
|
||
}}
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
navigator.geolocation?.getCurrentPosition((pos) => {
|
||
mapRef.current?.flyTo({
|
||
center: [pos.coords.longitude, pos.coords.latitude],
|
||
zoom: 15,
|
||
duration: 1000,
|
||
});
|
||
});
|
||
}}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 16,
|
||
marginTop: 4,
|
||
}}
|
||
title="My location"
|
||
>
|
||
⊙
|
||
</button>
|
||
</div>
|
||
|
||
{/* Drawing Toolbar (Mapus-style) */}
|
||
<div style={styles.toolbar} onPointerDown={stopPropagation}>
|
||
{/* Cursor Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('cursor')}
|
||
className={`mapus-tool ${activeTool === 'cursor' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Select"
|
||
>
|
||
↖️
|
||
</button>
|
||
|
||
{/* Marker Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('marker')}
|
||
className={`mapus-tool ${activeTool === 'marker' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Add marker"
|
||
>
|
||
📍
|
||
</button>
|
||
|
||
{/* Line Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('line')}
|
||
className={`mapus-tool ${activeTool === 'line' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Draw line"
|
||
>
|
||
📏
|
||
</button>
|
||
|
||
{/* Area Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('area')}
|
||
className={`mapus-tool ${activeTool === 'area' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Draw area"
|
||
>
|
||
⬡
|
||
</button>
|
||
|
||
{/* Eraser */}
|
||
<button
|
||
onClick={() => setActiveTool('eraser')}
|
||
className={`mapus-tool ${activeTool === 'eraser' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Eraser"
|
||
>
|
||
🧹
|
||
</button>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: 1, background: '#E8E8E8', margin: '4px 6px' }} />
|
||
|
||
{/* Color Picker */}
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||
style={{
|
||
...styles.toolButton,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
}}
|
||
title="Color"
|
||
>
|
||
<div style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: '50%',
|
||
background: selectedColor,
|
||
border: '2px solid #fff',
|
||
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||
}} />
|
||
</button>
|
||
|
||
{showColorPicker && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: '100%',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
marginBottom: 8,
|
||
background: '#fff',
|
||
borderRadius: 8,
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||
padding: 8,
|
||
display: 'flex',
|
||
gap: 6,
|
||
}}>
|
||
{COLORS.map((color) => (
|
||
<div
|
||
key={color}
|
||
className={`mapus-color ${selectedColor === color ? 'selected' : ''}`}
|
||
onClick={() => { setSelectedColor(color); setShowColorPicker(false); }}
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
background: color,
|
||
cursor: 'pointer',
|
||
transition: 'transform 0.15s',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Observing Indicator */}
|
||
{observingUser && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
left: 8,
|
||
right: 8,
|
||
bottom: 8,
|
||
pointerEvents: 'none',
|
||
border: `3px solid ${shape.props.collaborators.find((c: CollaboratorPresence) => c.id === observingUser)?.color || '#3b82f6'}`,
|
||
borderRadius: 8,
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: -30,
|
||
left: 10,
|
||
background: shape.props.collaborators.find((c: CollaboratorPresence) => c.id === observingUser)?.color || '#3b82f6',
|
||
color: '#fff',
|
||
padding: '4px 10px',
|
||
borderRadius: 4,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
}}>
|
||
👁️ Observing {shape.props.collaborators.find((c: CollaboratorPresence) => c.id === observingUser)?.name}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</StandardizedToolWrapper>
|
||
</HTMLContainer>
|
||
);
|
||
}
|
||
|
||
export default MapShape;
|