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 { 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 maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper';
// ============================================================================= // =============================================================================
// Types // Types
@ -76,6 +77,9 @@ export type IMapShape = TLBaseShape<
waypoints: Coordinate[]; waypoints: Coordinate[];
collaborators: CollaboratorPresence[]; collaborators: CollaboratorPresence[];
showSidebar: boolean; showSidebar: boolean;
pinnedToView: boolean;
tags: string[];
isMinimized: boolean;
// Legacy compatibility properties // Legacy compatibility properties
interactive: boolean; interactive: boolean;
showGPS: boolean; showGPS: boolean;
@ -168,6 +172,9 @@ type StyleKey = keyof typeof MAP_STYLES;
export class MapShape extends BaseBoxShapeUtil<IMapShape> { export class MapShape extends BaseBoxShapeUtil<IMapShape> {
static override type = 'Map' as const; static override type = 'Map' as const;
// Map theme color: Blue (consistent with mapping/navigation)
static readonly PRIMARY_COLOR = '#4890E8';
static override props = { static override props = {
w: T.number, w: T.number,
h: T.number, h: T.number,
@ -180,6 +187,9 @@ export class MapShape extends BaseBoxShapeUtil<IMapShape> {
waypoints: T.any, waypoints: T.any,
collaborators: T.any, collaborators: T.any,
showSidebar: T.boolean, showSidebar: T.boolean,
pinnedToView: T.boolean,
tags: T.any,
isMinimized: T.boolean,
// Legacy compatibility properties // Legacy compatibility properties
interactive: T.boolean, interactive: T.boolean,
showGPS: T.boolean, showGPS: T.boolean,
@ -202,6 +212,9 @@ export class MapShape extends BaseBoxShapeUtil<IMapShape> {
waypoints: [], waypoints: [],
collaborators: [], collaborators: [],
showSidebar: true, showSidebar: true,
pinnedToView: false,
tags: ['map'],
isMinimized: false,
// Legacy compatibility defaults // Legacy compatibility defaults
interactive: true, interactive: true,
showGPS: false, showGPS: false,
@ -220,11 +233,13 @@ export class MapShape extends BaseBoxShapeUtil<IMapShape> {
} }
indicator(shape: 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) { 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 // 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 containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map()); 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 [showColorPicker, setShowColorPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]); const [searchResults, setSearchResults] = useState<any[]>([]);
const [nearbyPlaces, setNearbyPlaces] = useState<any[]>([]); const [_nearbyPlaces, setNearbyPlaces] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [observingUser, setObservingUser] = useState<string | null>(null); const [observingUser, setObservingUser] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState(false);
const styleKey = (shape.props.styleKey || 'voyager') as StyleKey; const styleKey = (shape.props.styleKey || 'voyager') as StyleKey;
const currentStyle = MAP_STYLES[styleKey] || MAP_STYLES.voyager; const currentStyle = MAP_STYLES[styleKey] || MAP_STYLES.voyager;
@ -775,36 +789,74 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
// Event Handlers // Event Handlers
// ========================================================================== // ==========================================================================
const stopPropagation = useCallback((e: React.SyntheticEvent) => { const stopPropagation = useCallback((e: { stopPropagation: () => void }) => {
e.stopPropagation(); e.stopPropagation();
}, []); }, []);
// Prevent browser zoom when over the map - use map zoom instead // Handle wheel events on map container - forward delta to map for zooming
const handleWheel = useCallback((e: React.WheelEvent) => { const handleMapWheel = useCallback((e: React.WheelEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); 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 // Close handler for StandardizedToolWrapper
const capturePointerEvents = useCallback((e: React.PointerEvent) => { const handleClose = useCallback(() => {
e.stopPropagation(); 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 // Render
// ========================================================================== // ==========================================================================
const contentHeight = shape.props.h;
return ( return (
<HTMLContainer> <HTMLContainer style={{ width: shape.props.w, height: contentHeight + 40 }}>
<style>{` <style>{`
.mapus-sidebar::-webkit-scrollbar { width: 6px; } .mapus-sidebar::-webkit-scrollbar { width: 6px; }
.mapus-sidebar::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; } .mapus-sidebar::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
.mapus-btn:hover { background: #f7f7f7 !important; } .mapus-btn:hover { background: #f7f7f7 !important; }
.mapus-btn:active { transform: scale(0.97); } .mapus-btn:active { transform: scale(0.97); }
.mapus-category:hover { background: #f7f7f7; } .mapus-category:hover { background: #f7f7f7 !important; }
.mapus-annotation:hover { background: #f7f7f7; } .mapus-annotation:hover { background: #f7f7f7; }
.mapus-result:hover { background: #f3f4f6; } .mapus-result:hover { background: #f3f4f6 !important; }
.mapus-tool:hover { background: #f7f7f7; } .mapus-tool:hover { background: #f7f7f7; }
.mapus-tool.active { background: #222 !important; color: #fff !important; } .mapus-tool.active { background: #222 !important; color: #fff !important; }
.mapus-color:hover { transform: scale(1.15); } .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; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
`}</style> `}</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 <div
style={{ style={{
width: shape.props.w, width: '100%',
height: shape.props.h, height: '100%',
borderRadius: 8,
overflow: 'hidden',
background: '#e5e7eb',
display: 'flex', display: 'flex',
position: 'relative', position: 'relative',
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', 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 */} {/* Left Sidebar */}
{shape.props.showSidebar && ( {shape.props.showSidebar && (
<div className="mapus-sidebar" style={styles.sidebar}> <div
{/* Map Details */} className="mapus-sidebar"
<div style={styles.section}> style={styles.sidebar}
<input onPointerDown={stopPropagation}
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>
{/* Search */} {/* Search */}
<div style={styles.section}> <div style={styles.section}>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
@ -884,6 +908,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
e.stopPropagation(); e.stopPropagation();
if (e.key === 'Enter') searchPlaces(); if (e.key === 'Enter') searchPlaces();
}} }}
onPointerDown={stopPropagation}
placeholder="Search for a place..." placeholder="Search for a place..."
style={{ style={{
flex: 1, flex: 1,
@ -896,6 +921,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
/> />
<button <button
onClick={searchPlaces} onClick={searchPlaces}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ style={{
...styles.button, ...styles.button,
@ -915,6 +941,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
key={i} key={i}
className="mapus-result" className="mapus-result"
onClick={() => selectSearchResult(result)} onClick={() => selectSearchResult(result)}
onPointerDown={stopPropagation}
style={{ style={{
padding: '10px 8px', padding: '10px 8px',
cursor: 'pointer', cursor: 'pointer',
@ -938,6 +965,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
key={cat.key} key={cat.key}
className="mapus-category" className="mapus-category"
onClick={() => findNearby(cat)} onClick={() => findNearby(cat)}
onPointerDown={stopPropagation}
style={{ style={{
textAlign: 'center', textAlign: 'center',
padding: '10px 4px', padding: '10px 4px',
@ -963,6 +991,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
key={collab.id} key={collab.id}
className="mapus-annotation" className="mapus-annotation"
onClick={() => observeUser(observingUser === collab.id ? null : collab.id)} onClick={() => observeUser(observingUser === collab.id ? null : collab.id)}
onPointerDown={stopPropagation}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -1004,6 +1033,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
<div style={styles.sectionTitle}>Annotations</div> <div style={styles.sectionTitle}>Annotations</div>
<button <button
onClick={hideAllAnnotations} onClick={hideAllAnnotations}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ ...styles.button, fontSize: 12, padding: '4px 8px', color: '#626C72' }} style={{ ...styles.button, fontSize: 12, padding: '4px 8px', color: '#626C72' }}
> >
@ -1021,6 +1051,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
<div <div
key={ann.id} key={ann.id}
className="mapus-annotation" className="mapus-annotation"
onPointerDown={stopPropagation}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -1042,6 +1073,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
</div> </div>
<button <button
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }} onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ ...styles.button, padding: 4, fontSize: 14 }} style={{ ...styles.button, padding: 4, fontSize: 14 }}
> >
@ -1049,6 +1081,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }} onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ ...styles.button, padding: 4, fontSize: 14, color: '#E15F59' }} style={{ ...styles.button, padding: 4, fontSize: 14, color: '#E15F59' }}
> >
@ -1068,12 +1101,16 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
)} )}
{/* Map Container */} {/* Map Container */}
<div style={{ flex: 1, position: 'relative' }}> <div
style={{ flex: 1, position: 'relative' }}
onWheel={handleMapWheel}
>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} /> <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{/* Sidebar Toggle */} {/* Sidebar Toggle */}
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ style={{
position: 'absolute', position: 'absolute',
@ -1090,18 +1127,18 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: 18, fontSize: 18,
zIndex: 10000, zIndex: 10,
pointerEvents: 'auto',
}} }}
> >
{shape.props.showSidebar ? '◀' : '▶'} {shape.props.showSidebar ? '◀' : '▶'}
</button> </button>
{/* Style Picker */} {/* 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 <select
value={styleKey} value={styleKey}
onChange={(e) => changeStyle(e.target.value as StyleKey)} onChange={(e) => changeStyle(e.target.value as StyleKey)}
onPointerDown={stopPropagation}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
borderRadius: 6, 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)', boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
fontSize: 13, fontSize: 13,
cursor: 'pointer', cursor: 'pointer',
pointerEvents: 'auto',
}} }}
> >
{Object.entries(MAP_STYLES).map(([key, style]) => ( {Object.entries(MAP_STYLES).map(([key, style]) => (
@ -1120,9 +1156,10 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
</div> </div>
{/* Zoom Controls */} {/* 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 <button
onClick={() => mapRef.current?.zoomIn()} onClick={() => mapRef.current?.zoomIn()}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ style={{
width: 36, 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)', boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
cursor: 'pointer', cursor: 'pointer',
fontSize: 18, fontSize: 18,
pointerEvents: 'auto',
}} }}
> >
+ +
</button> </button>
<button <button
onClick={() => mapRef.current?.zoomOut()} onClick={() => mapRef.current?.zoomOut()}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ style={{
width: 36, 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)', boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
cursor: 'pointer', cursor: 'pointer',
fontSize: 18, fontSize: 18,
pointerEvents: 'auto',
}} }}
> >
@ -1165,6 +1201,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
}); });
}); });
}} }}
onPointerDown={stopPropagation}
className="mapus-btn" className="mapus-btn"
style={{ style={{
width: 36, width: 36,
@ -1176,7 +1213,6 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
cursor: 'pointer', cursor: 'pointer',
fontSize: 16, fontSize: 16,
marginTop: 4, marginTop: 4,
pointerEvents: 'auto',
}} }}
title="My location" title="My location"
> >
@ -1185,7 +1221,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
</div> </div>
{/* Drawing Toolbar (Mapus-style) */} {/* Drawing Toolbar (Mapus-style) */}
<div style={styles.toolbar}> <div style={styles.toolbar} onPointerDown={stopPropagation}>
{/* Cursor Tool */} {/* Cursor Tool */}
<button <button
onClick={() => setActiveTool('cursor')} onClick={() => setActiveTool('cursor')}
@ -1324,6 +1360,7 @@ function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['e
)} )}
</div> </div>
</div> </div>
</StandardizedToolWrapper>
</HTMLContainer> </HTMLContainer>
); );
} }