feat: add StandardizedToolWrapper and fix map interactions
- Wrap map component in StandardizedToolWrapper with header bar
- Add onPointerDown={stopPropagation} to all sidebar interactive elements
- Add handleMapWheel that forwards wheel zoom to map component
- Add pinnedToView, tags, isMinimized props for consistency
- Fix TypeScript type for stopPropagation handler
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc048d7aec
commit
ee3ec16cb6
|
|
@ -15,9 +15,10 @@
|
|||
*/
|
||||
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLResizeInfo, resizeBox, T } from 'tldraw';
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -76,6 +77,9 @@ export type IMapShape = TLBaseShape<
|
|||
waypoints: Coordinate[];
|
||||
collaborators: CollaboratorPresence[];
|
||||
showSidebar: boolean;
|
||||
pinnedToView: boolean;
|
||||
tags: string[];
|
||||
isMinimized: boolean;
|
||||
// Legacy compatibility properties
|
||||
interactive: boolean;
|
||||
showGPS: boolean;
|
||||
|
|
@ -168,6 +172,9 @@ type StyleKey = keyof typeof MAP_STYLES;
|
|||
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,
|
||||
|
|
@ -180,6 +187,9 @@ export class MapShape extends BaseBoxShapeUtil<IMapShape> {
|
|||
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,
|
||||
|
|
@ -202,6 +212,9 @@ export class MapShape extends BaseBoxShapeUtil<IMapShape> {
|
|||
waypoints: [],
|
||||
collaborators: [],
|
||||
showSidebar: true,
|
||||
pinnedToView: false,
|
||||
tags: ['map'],
|
||||
isMinimized: false,
|
||||
// Legacy compatibility defaults
|
||||
interactive: true,
|
||||
showGPS: false,
|
||||
|
|
@ -220,11 +233,13 @@ export class MapShape extends BaseBoxShapeUtil<IMapShape> {
|
|||
}
|
||||
|
||||
indicator(shape: IMapShape) {
|
||||
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} fill="none" rx={8} />;
|
||||
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) {
|
||||
return <MapComponent shape={shape} editor={this.editor} />;
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id);
|
||||
return <MapComponent shape={shape} editor={this.editor} isSelected={isSelected} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -303,7 +318,7 @@ const styles = {
|
|||
// Map Component
|
||||
// =============================================================================
|
||||
|
||||
function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['editor'] }) {
|
||||
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());
|
||||
|
|
@ -315,10 +330,9 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [nearbyPlaces, setNearbyPlaces] = useState<any[]>([]);
|
||||
const [_nearbyPlaces, setNearbyPlaces] = useState<any[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [observingUser, setObservingUser] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
|
||||
const styleKey = (shape.props.styleKey || 'voyager') as StyleKey;
|
||||
const currentStyle = MAP_STYLES[styleKey] || MAP_STYLES.voyager;
|
||||
|
|
@ -775,36 +789,74 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
// Event Handlers
|
||||
// ==========================================================================
|
||||
|
||||
const stopPropagation = useCallback((e: React.SyntheticEvent) => {
|
||||
const stopPropagation = useCallback((e: { stopPropagation: () => void }) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
// Prevent browser zoom when over the map - use map zoom instead
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Handle wheel events on map container - forward delta to map for zooming
|
||||
const handleMapWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
// The map will handle zooming via its own wheel handler
|
||||
// Forward wheel event to the map for zooming
|
||||
if (mapRef.current) {
|
||||
const map = mapRef.current;
|
||||
const delta = e.deltaY > 0 ? -1 : 1;
|
||||
const currentZoom = map.getZoom();
|
||||
map.easeTo({
|
||||
zoom: currentZoom + delta * 0.5,
|
||||
duration: 150,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Capture all pointer events to prevent tldraw from intercepting
|
||||
const capturePointerEvents = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
// 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>
|
||||
<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; }
|
||||
.mapus-category:hover { background: #f7f7f7 !important; }
|
||||
.mapus-annotation:hover { background: #f7f7f7; }
|
||||
.mapus-result:hover { background: #f3f4f6; }
|
||||
.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); }
|
||||
|
|
@ -812,67 +864,39 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
@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}
|
||||
editor={editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags || ['map']}
|
||||
onTagsChange={handleTagsChange}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: '#e5e7eb',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onPointerDown={capturePointerEvents}
|
||||
onPointerMove={capturePointerEvents}
|
||||
onPointerUp={capturePointerEvents}
|
||||
onWheel={handleWheel}
|
||||
onClick={stopPropagation}
|
||||
onDoubleClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
onContextMenu={stopPropagation}
|
||||
>
|
||||
{/* Left Sidebar */}
|
||||
{shape.props.showSidebar && (
|
||||
<div className="mapus-sidebar" style={styles.sidebar}>
|
||||
{/* Map Details */}
|
||||
<div style={styles.section}>
|
||||
<input
|
||||
value={shape.props.title}
|
||||
onChange={(e) => updateTitle(e.target.value)}
|
||||
onFocus={() => setEditingTitle(true)}
|
||||
onBlur={() => setEditingTitle(false)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#222',
|
||||
background: 'transparent',
|
||||
borderBottom: editingTitle ? '2px solid #E8E8E8' : '2px solid transparent',
|
||||
outline: 'none',
|
||||
padding: 0,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={shape.props.description}
|
||||
onChange={(e) => updateDescription(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
fontSize: 13,
|
||||
color: '#626C72',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
padding: 0,
|
||||
}}
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mapus-sidebar"
|
||||
style={styles.sidebar}
|
||||
onPointerDown={stopPropagation}
|
||||
>
|
||||
{/* Search */}
|
||||
<div style={styles.section}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
|
|
@ -884,6 +908,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
e.stopPropagation();
|
||||
if (e.key === 'Enter') searchPlaces();
|
||||
}}
|
||||
onPointerDown={stopPropagation}
|
||||
placeholder="Search for a place..."
|
||||
style={{
|
||||
flex: 1,
|
||||
|
|
@ -896,6 +921,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
/>
|
||||
<button
|
||||
onClick={searchPlaces}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{
|
||||
...styles.button,
|
||||
|
|
@ -915,6 +941,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
key={i}
|
||||
className="mapus-result"
|
||||
onClick={() => selectSearchResult(result)}
|
||||
onPointerDown={stopPropagation}
|
||||
style={{
|
||||
padding: '10px 8px',
|
||||
cursor: 'pointer',
|
||||
|
|
@ -938,6 +965,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
key={cat.key}
|
||||
className="mapus-category"
|
||||
onClick={() => findNearby(cat)}
|
||||
onPointerDown={stopPropagation}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px 4px',
|
||||
|
|
@ -963,6 +991,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
key={collab.id}
|
||||
className="mapus-annotation"
|
||||
onClick={() => observeUser(observingUser === collab.id ? null : collab.id)}
|
||||
onPointerDown={stopPropagation}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
@ -1004,6 +1033,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
<div style={styles.sectionTitle}>Annotations</div>
|
||||
<button
|
||||
onClick={hideAllAnnotations}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{ ...styles.button, fontSize: 12, padding: '4px 8px', color: '#626C72' }}
|
||||
>
|
||||
|
|
@ -1021,6 +1051,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
<div
|
||||
key={ann.id}
|
||||
className="mapus-annotation"
|
||||
onPointerDown={stopPropagation}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
@ -1042,6 +1073,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{ ...styles.button, padding: 4, fontSize: 14 }}
|
||||
>
|
||||
|
|
@ -1049,6 +1081,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{ ...styles.button, padding: 4, fontSize: 14, color: '#E15F59' }}
|
||||
>
|
||||
|
|
@ -1068,12 +1101,16 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
)}
|
||||
|
||||
{/* Map Container */}
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<div
|
||||
style={{ flex: 1, position: 'relative' }}
|
||||
onWheel={handleMapWheel}
|
||||
>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
|
||||
{/* Sidebar Toggle */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
|
@ -1090,18 +1127,18 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 18,
|
||||
zIndex: 10000,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{shape.props.showSidebar ? '◀' : '▶'}
|
||||
</button>
|
||||
|
||||
{/* Style Picker */}
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10000, pointerEvents: 'auto' }}>
|
||||
<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,
|
||||
|
|
@ -1110,7 +1147,6 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{Object.entries(MAP_STYLES).map(([key, style]) => (
|
||||
|
|
@ -1120,9 +1156,10 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div style={{ position: 'absolute', bottom: 80, right: 10, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10000, pointerEvents: 'auto' }}>
|
||||
<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,
|
||||
|
|
@ -1133,13 +1170,13 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 18,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mapRef.current?.zoomOut()}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{
|
||||
width: 36,
|
||||
|
|
@ -1150,7 +1187,6 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 18,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
−
|
||||
|
|
@ -1165,6 +1201,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
});
|
||||
});
|
||||
}}
|
||||
onPointerDown={stopPropagation}
|
||||
className="mapus-btn"
|
||||
style={{
|
||||
width: 36,
|
||||
|
|
@ -1176,7 +1213,6 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
marginTop: 4,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
title="My location"
|
||||
>
|
||||
|
|
@ -1185,7 +1221,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
</div>
|
||||
|
||||
{/* Drawing Toolbar (Mapus-style) */}
|
||||
<div style={styles.toolbar}>
|
||||
<div style={styles.toolbar} onPointerDown={stopPropagation}>
|
||||
{/* Cursor Tool */}
|
||||
<button
|
||||
onClick={() => setActiveTool('cursor')}
|
||||
|
|
@ -1324,6 +1360,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue