Addressed PR comments about the textarea auto growing, the in flight notes changes asking for confirmation, and updated the hue sort to sort by lightness and then hue so that grayscale values will not collide with red
This commit is contained in:
parent
4a348c94e7
commit
aad4d2af7c
|
|
@ -27,11 +27,18 @@ export default function MapComponent() {
|
|||
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
|
||||
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
|
||||
const [editingMarkerId, setEditingMarkerId] = useState<number | null>(null)
|
||||
const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false)
|
||||
|
||||
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
|
||||
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
|
||||
)
|
||||
|
||||
const confirmDiscardMarkerChanges = useCallback(() => {
|
||||
if (!hasUnsavedMarkerChanges) return true
|
||||
|
||||
return window.confirm('Discard unsaved marker changes?')
|
||||
}, [hasUnsavedMarkerChanges])
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = new Protocol()
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
||||
|
|
@ -46,11 +53,17 @@ export default function MapComponent() {
|
|||
localStorage.setItem('nomad:map-scale-unit', unit)
|
||||
}, [])
|
||||
|
||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
|
||||
setSelectedMarkerId(null)
|
||||
setEditingMarkerId(null)
|
||||
}, [])
|
||||
const handleMapClick = useCallback(
|
||||
(e: MapLayerMouseEvent) => {
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
|
||||
setSelectedMarkerId(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
},
|
||||
[confirmDiscardMarkerChanges]
|
||||
)
|
||||
|
||||
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
|
||||
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
||||
|
|
@ -104,9 +117,13 @@ export default function MapComponent() {
|
|||
anchor="bottom"
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation()
|
||||
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
>
|
||||
<MarkerPin
|
||||
|
|
@ -124,7 +141,13 @@ export default function MapComponent() {
|
|||
await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined)
|
||||
setPlacingMarker(null)
|
||||
}}
|
||||
onCancel={() => setPlacingMarker(null)}
|
||||
onCancel={() => {
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -141,6 +164,7 @@ export default function MapComponent() {
|
|||
longitude={selectedMarker.longitude}
|
||||
latitude={selectedMarker.latitude}
|
||||
initialMarker={selectedMarker}
|
||||
onDirtyChange={setHasUnsavedMarkerChanges}
|
||||
onSave={async ({ id, name, notes, color }) => {
|
||||
if (!id) return
|
||||
|
||||
|
|
@ -151,6 +175,7 @@ export default function MapComponent() {
|
|||
})
|
||||
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
onCancel={() => setEditingMarkerId(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'
|
||||
import { Popup } from 'react-map-gl/maplibre'
|
||||
|
||||
import { PIN_COLORS } from '~/hooks/useMapMarkers'
|
||||
|
|
@ -20,6 +20,7 @@ type MapMarkerFormPopupProps = {
|
|||
color: PinColorId
|
||||
}) => void
|
||||
onCancel: () => void
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
}
|
||||
|
||||
export default function MapMarkerFormPopup({
|
||||
|
|
@ -28,11 +29,39 @@ export default function MapMarkerFormPopup({
|
|||
initialMarker,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
}: MapMarkerFormPopupProps) {
|
||||
const [name, setName] = useState(initialMarker?.name ?? '')
|
||||
const [notes, setNotes] = useState(initialMarker?.notes ?? '')
|
||||
const [color, setColor] = useState<PinColorId>(initialMarker?.color ?? 'orange')
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const resizeTextarea = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${textarea.scrollHeight}px`
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
resizeTextarea()
|
||||
}, [resizeTextarea])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
resizeTextarea()
|
||||
}, [])
|
||||
|
||||
const isDirty =
|
||||
name !== (initialMarker?.name ?? '') ||
|
||||
notes !== (initialMarker?.notes ?? '') ||
|
||||
color !== (initialMarker?.color ?? 'orange')
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty)
|
||||
}, [isDirty, onDirtyChange])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return
|
||||
|
||||
|
|
@ -68,16 +97,16 @@ export default function MapMarkerFormPopup({
|
|||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Add notes (optional)"
|
||||
value={notes}
|
||||
rows={2}
|
||||
maxLength={MAX_MARKER_NOTES_LENGTH}
|
||||
onChange={(e) => {
|
||||
setNotes(e.target.value)
|
||||
e.currentTarget.style.height = 'auto'
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
requestAnimationFrame(resizeTextarea)
|
||||
}}
|
||||
className={`mt-1 min-h-[64px] resize-none overflow-hidden ${inputClass}`}
|
||||
className={`mt-1 min-h-[64px] max-h-[240px] resize-none overflow-y-auto themed-scrollbar ${inputClass}`}
|
||||
/>
|
||||
|
||||
<div className="mt-1 text-[11px] text-gray-400">
|
||||
|
|
|
|||
|
|
@ -20,10 +20,13 @@ const normalizeColorHex = (color: string) => {
|
|||
return preset?.hex ?? color
|
||||
}
|
||||
|
||||
const getHueSortValue = (color: string) => {
|
||||
const getColorSortValue = (color: string) => {
|
||||
const hex = normalizeColorHex(color).replace('#', '')
|
||||
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return 0
|
||||
// Invalid/custom non-hex colors sort after valid colors
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
return { bucket: 2, hue: 0, lightness: 0 }
|
||||
}
|
||||
|
||||
const r = parseInt(hex.slice(0, 2), 16) / 255
|
||||
const g = parseInt(hex.slice(2, 4), 16) / 255
|
||||
|
|
@ -32,8 +35,12 @@ const getHueSortValue = (color: string) => {
|
|||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const delta = max - min
|
||||
const lightness = (max + min) / 2
|
||||
|
||||
if (delta === 0) return 0
|
||||
// Grayscale colors sort separately before hue colors, by lightness
|
||||
if (delta === 0) {
|
||||
return { bucket: 0, hue: 0, lightness }
|
||||
}
|
||||
|
||||
let hue = 0
|
||||
|
||||
|
|
@ -45,7 +52,11 @@ const getHueSortValue = (color: string) => {
|
|||
hue = (r - g) / delta + 4
|
||||
}
|
||||
|
||||
return Math.round(hue * 60 + 360) % 360
|
||||
return {
|
||||
bucket: 1,
|
||||
hue: Math.round(hue * 60 + 360) % 360,
|
||||
lightness,
|
||||
}
|
||||
}
|
||||
|
||||
export default function MarkerPanel({
|
||||
|
|
@ -81,7 +92,16 @@ export default function MarkerPanel({
|
|||
const result =
|
||||
sortField === 'name'
|
||||
? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||
: getHueSortValue(a.color) - getHueSortValue(b.color)
|
||||
: (() => {
|
||||
const aColor = getColorSortValue(a.color)
|
||||
const bColor = getColorSortValue(b.color)
|
||||
|
||||
return (
|
||||
aColor.bucket - bColor.bucket ||
|
||||
aColor.hue - bColor.hue ||
|
||||
aColor.lightness - bColor.lightness
|
||||
)
|
||||
})()
|
||||
|
||||
return sortDirection === 'asc' ? result : -result
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,47 +16,49 @@ export default function ViewMapMarkerPopup({
|
|||
onEdit,
|
||||
}: ViewMapMarkerPopupProps) {
|
||||
return (
|
||||
<Popup
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
offset={[0, -36] as [number, number]}
|
||||
onClose={onClose}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="text-sm font-medium">{marker.name}</div>
|
||||
|
||||
{marker.notes && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
<div className="max-w-[260px]">
|
||||
<Popup
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
offset={[0, -36] as [number, number]}
|
||||
onClose={onClose}
|
||||
closeOnClick={false}
|
||||
>
|
||||
{marker.notes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium">{marker.name}</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530]"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{marker.notes && (
|
||||
<div className="mt-1 max-w-[240px] break-all whitespace-pre-wrap text-xs text-gray-500">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({href, children}) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{marker.notes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530]"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,3 +158,50 @@ body {
|
|||
color: #f7eedc;
|
||||
background: #353420;
|
||||
}
|
||||
|
||||
/* Base (hidden scrollbar + fade hint) */
|
||||
.themed-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black 8px,
|
||||
black calc(100% - 8px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Chrome / Safari */
|
||||
.themed-scrollbar::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
/* Show scrollbar on hover OR focus */
|
||||
.themed-scrollbar:hover,
|
||||
.themed-scrollbar:focus {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-default) transparent;
|
||||
}
|
||||
|
||||
.themed-scrollbar:hover::-webkit-scrollbar,
|
||||
.themed-scrollbar:focus::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.themed-scrollbar:hover::-webkit-scrollbar-track,
|
||||
.themed-scrollbar:focus::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.themed-scrollbar:hover::-webkit-scrollbar-thumb,
|
||||
.themed-scrollbar:focus::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border-default);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.themed-scrollbar:hover::-webkit-scrollbar-thumb:hover,
|
||||
.themed-scrollbar:focus::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue