diff --git a/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx b/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx index 03e2f6b2f..3042a5e11 100644 --- a/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx +++ b/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx @@ -12,6 +12,11 @@ import { } from "@geolibre/ui"; import { MapPin, Layers, MessageSquare, Send, User } from "lucide-react"; import type { PendingCommentState } from "./useCommentTool"; +import { formatShortcut, isMacPlatform, matchesShortcut, type Shortcut } from "../../lib/commands"; + +// `shift` is explicit: omitting it means "ignored", which would post on +// Ctrl/⌘+Shift+Enter too, a chord the button never advertises. +export const POST_COMMENT_SHORTCUT: Shortcut = { key: "Enter", mod: true, shift: false }; interface AddCommentDialogProps { pendingComment: PendingCommentState; @@ -34,6 +39,8 @@ export function AddCommentDialog({ pendingComment, onSubmit, onCancel }: AddComm const [text, setText] = useState(""); const [authorName, setAuthorName] = useState(savedName ?? ""); + const canSubmit = !!text.trim() && (hasSavedName || !!authorName.trim()); + const shortcutLabel = formatShortcut(POST_COMMENT_SHORTCUT, isMacPlatform()); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -74,7 +81,19 @@ export function AddCommentDialog({ pendingComment, onSubmit, onCancel }: AddComm -
+ { + if ( + canSubmit && + matchesShortcut(event.nativeEvent, POST_COMMENT_SHORTCUT, isMacPlatform()) + ) { + event.preventDefault(); + event.currentTarget.requestSubmit(); + } + }} + className="space-y-3.5" + >
{anchor.type === "feature" ? ( <> @@ -163,10 +182,13 @@ export function AddCommentDialog({ pendingComment, onSubmit, onCancel }: AddComm variant="default" size="sm" className="gap-1.5" - disabled={!text.trim() || (!hasSavedName && !authorName.trim())} + disabled={!canSubmit} + title={t("comments.postShortcutTooltip", { shortcut: shortcutLabel })} + aria-keyshortcuts="Control+Enter Meta+Enter" > - Post Comment + {t("comments.post")} + {shortcutLabel}
diff --git a/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx b/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx index f9ab23589..e4fe74420 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx @@ -131,11 +131,18 @@ export function CommentMapOverlay({ const pinColor = comment.author?.color || "#3b82f6"; + // MapLibre writes its geographic translate transform onto the marker + // element itself. Keep that outer element transform-free and animate a + // child instead; a hover transform on `container` would replace + // MapLibre's translate and make the marker jump across the viewport. const container = document.createElement("div"); - container.className = - "group relative cursor-pointer select-none transition-transform duration-150 ease-out hover:scale-[1.15]"; + container.className = "relative cursor-pointer select-none"; container.style.zIndex = comment.resolved ? "9" : "10"; + const hoverTarget = document.createElement("div"); + hoverTarget.className = + "origin-bottom transition-transform duration-150 ease-out hover:scale-[1.15]"; + // Build the pin with DOM APIs so the author color is set as a style // property, never interpolated into markup — defense-in-depth against // a hand-edited project file with a hostile color value. @@ -159,7 +166,8 @@ export function CommentMapOverlay({ "transform:rotate(45deg);color:#ffffff;font-size:11px;font-weight:700;font-family:system-ui,sans-serif;line-height:1"; label.textContent = `#${idx + 1}`; pin.appendChild(label); - container.appendChild(pin); + hoverTarget.appendChild(pin); + container.appendChild(hoverTarget); container.addEventListener("click", (e) => { e.stopPropagation(); diff --git a/apps/geolibre-desktop/src/components/comments/CommentThread.tsx b/apps/geolibre-desktop/src/components/comments/CommentThread.tsx index fc1a0d28b..e78c126dc 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentThread.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentThread.tsx @@ -21,6 +21,7 @@ interface CommentThreadProps { onDelete: (commentId: string) => void; onZoomTo: (comment: ProjectComment) => void; readOnly?: boolean; + selected?: boolean; } export function CommentThread({ @@ -31,6 +32,7 @@ export function CommentThread({ onDelete, onZoomTo, readOnly = false, + selected = false, }: CommentThreadProps) { const { t } = useTranslation(); const [replyText, setReplyText] = useState(""); @@ -51,6 +53,7 @@ export function CommentThread({ comment.resolved ? "bg-muted/30 border-border/40 opacity-70" : "bg-card border-border shadow-xs hover:border-border/80", + selected && "border-primary ring-1 ring-inset ring-primary", )} > {/* Thread Header */} diff --git a/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx b/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx index 6ab5debec..c5e7ddd43 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx @@ -15,6 +15,7 @@ import { Check, } from "lucide-react"; import { v4 as uuidv4 } from "uuid"; +import { useTranslation } from "react-i18next"; import { CommentThread } from "./CommentThread"; import { resolveCommentCoordinates } from "./CommentMapOverlay"; import type { CollaborationApi } from "../../hooks/useCollaboration"; @@ -51,6 +52,10 @@ interface CommentsPanelProps { /** Called when the resolved-pins visibility should change. Receives `true` * when the "Resolved" or "All" filter is active, `false` for "Open". */ onShowResolvedChange?: (showResolved: boolean) => void; + /** Comment selected from its map marker. The matching card is revealed, + * highlighted, and scrolled into view. */ + selectedCommentId?: string | null; + onClearSelectedComment?: () => void; } export function CommentsPanel({ @@ -59,7 +64,10 @@ export function CommentsPanel({ onActivateCommentTool, isCommentToolActive, onShowResolvedChange, + selectedCommentId, + onClearSelectedComment, }: CommentsPanelProps) { + const { t } = useTranslation(); const comments = useAppStore((s) => s.comments); const replyToComment = useAppStore((s) => s.replyToComment); const toggleResolveComment = useAppStore((s) => s.toggleResolveComment); @@ -67,6 +75,36 @@ export function CommentsPanel({ const collab = useAppStore((s) => s.collaboration); const [filter, setFilter] = useState<"all" | "open" | "resolved">("open"); + const commentCardRefs = useRef(new Map()); + const revealedSelectionRef = useRef(null); + + useEffect(() => { + if (!selectedCommentId) { + revealedSelectionRef.current = null; + return; + } + if (revealedSelectionRef.current === selectedCommentId) return; + const selected = comments.find((comment) => comment.id === selectedCommentId); + if (!selected) return; + revealedSelectionRef.current = selectedCommentId; + + // A resolved marker can remain visible after the panel was displaced and + // remounted with its default Open filter. Reveal whichever filter contains + // the selected card before trying to scroll to it. + if ((selected.resolved && filter === "open") || (!selected.resolved && filter === "resolved")) { + setFilter(selected.resolved ? "resolved" : "open"); + } + }, [comments, filter, selectedCommentId]); + + useEffect(() => { + if (!selectedCommentId) return; + const frame = requestAnimationFrame(() => { + commentCardRefs.current + .get(selectedCommentId) + ?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }); + return () => cancelAnimationFrame(frame); + }, [filter, selectedCommentId]); // Notify parent whenever resolved pins should show/hide so the map overlay // stays in sync with the sidebar filter. @@ -112,10 +150,12 @@ export function CommentsPanel({ // The real session code comes from the store once start() has resolved. const activeCode = collab.sessionId ?? ""; - const handleCopyCode = async () => { + const handleCopySessionUrl = async () => { if (!activeCode) return; try { - await navigator.clipboard.writeText(activeCode); + const sessionUrl = new URL(window.location.href); + sessionUrl.searchParams.set("collab", activeCode); + await navigator.clipboard.writeText(sessionUrl.toString()); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { @@ -225,6 +265,9 @@ export function CommentsPanel({ > Add Comment + + C + )} @@ -299,9 +342,9 @@ export function CommentsPanel({ type="button" variant="outline" size="sm" - onClick={handleCopyCode} + onClick={handleCopySessionUrl} className="h-7 px-2 text-[11px] shrink-0" - title="Copy session code" + title={t("collaborate.copyLink")} > {copied ? "Copied" : "Copy"} @@ -392,7 +435,10 @@ export function CommentsPanel({