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:
Jeff Emmett 2025-12-07 12:44:48 -08:00
parent dc048d7aec
commit ee3ec16cb6
1 changed files with 528 additions and 491 deletions

View File

@ -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>
);
}