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:
Kenneth Brewer 2026-04-29 02:13:43 -04:00
parent 4a348c94e7
commit aad4d2af7c
5 changed files with 179 additions and 56 deletions

View File

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

View File

@ -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">

View File

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

View File

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

View File

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