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
-
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({