Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -74,7 +81,19 @@ export function AddCommentDialog({ pendingComment, onSubmit, onCancel }: AddComm
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="space-y-3.5">
<form
onSubmit={handleSubmit}
onKeyDown={(event) => {
if (
canSubmit &&
matchesShortcut(event.nativeEvent, POST_COMMENT_SHORTCUT, isMacPlatform())
) {
event.preventDefault();
event.currentTarget.requestSubmit();
}
}}
className="space-y-3.5"
>
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-2.5 rounded-md border border-border/60">
{anchor.type === "feature" ? (
<>
Expand Down Expand Up @@ -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"
>
<Send className="h-3.5 w-3.5" />
<span>Post Comment</span>
<span>{t("comments.post")}</span>
<kbd className="ms-1 text-[10px] font-normal opacity-70">{shortcutLabel}</kbd>
</Button>
</div>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface CommentThreadProps {
onDelete: (commentId: string) => void;
onZoomTo: (comment: ProjectComment) => void;
readOnly?: boolean;
selected?: boolean;
}

export function CommentThread({
Expand All @@ -31,6 +32,7 @@ export function CommentThread({
onDelete,
onZoomTo,
readOnly = false,
selected = false,
}: CommentThreadProps) {
const { t } = useTranslation();
const [replyText, setReplyText] = useState("");
Expand All @@ -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 */}
Expand Down
86 changes: 72 additions & 14 deletions apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -59,14 +64,47 @@ 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);
const deleteComment = useAppStore((s) => s.deleteComment);
const collab = useAppStore((s) => s.collaboration);

const [filter, setFilter] = useState<"all" | "open" | "resolved">("open");
const commentCardRefs = useRef(new Map<string, HTMLDivElement>());
const revealedSelectionRef = useRef<string | null>(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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -225,6 +265,9 @@ export function CommentsPanel({
>
<Plus className="h-3.5 w-3.5" />
<span>Add Comment</span>
<kbd className="ms-1 rounded border border-current/25 px-1 font-mono text-[9px] leading-4 opacity-70">
C
</kbd>
</Button>
)}
</div>
Expand Down Expand Up @@ -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")}
>
<Copy className="h-3 w-3 me-1" />
{copied ? "Copied" : "Copy"}
Expand Down Expand Up @@ -392,7 +435,10 @@ export function CommentsPanel({
<button
key={f}
type="button"
onClick={() => setFilter(f)}
onClick={() => {
onClearSelectedComment?.();
setFilter(f);
}}
className={cn(
"flex-1 py-1 px-2 text-[11px] font-medium rounded transition-colors text-center",
filter === f
Expand Down Expand Up @@ -426,17 +472,29 @@ export function CommentsPanel({
<div className="space-y-3">
{filteredComments.map((comment) => {
const originalIndex = comments.findIndex((c) => c.id === comment.id);
const selected = comment.id === selectedCommentId;
return (
<CommentThread
<div
key={comment.id}
comment={comment}
index={originalIndex >= 0 ? originalIndex : 0}
onReply={handleReply}
onToggleResolve={handleToggleResolve}
onDelete={handleDelete}
onZoomTo={handleZoomTo}
readOnly={!canModifyComments}
/>
ref={(element) => {
if (element) commentCardRefs.current.set(comment.id, element);
else commentCardRefs.current.delete(comment.id);
}}
data-comment-id={comment.id}
data-selected={selected || undefined}
className="rounded-lg"
>
<CommentThread
comment={comment}
index={originalIndex >= 0 ? originalIndex : 0}
onReply={handleReply}
onToggleResolve={handleToggleResolve}
onDelete={handleDelete}
onZoomTo={handleZoomTo}
readOnly={!canModifyComments}
selected={selected}
/>
</div>
);
})}
</div>
Expand Down
9 changes: 8 additions & 1 deletion apps/geolibre-desktop/src/components/layout/DesktopShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,7 @@
const collaboration = useCollaboration(mapControllerRef);
const commentTool = useCommentTool({ mapControllerRef, collaboration });
const [showResolvedComments, setShowResolvedComments] = useState(false);
const [selectedCommentId, setSelectedCommentId] = useState<string | null>(null);
const collaborateDialogOpen = useAppStore((s) => s.ui.collaborateDialogOpen);
const setCollaborateDialogOpen = useAppStore((s) => s.setCollaborateDialogOpen);
// When opened via a `?collab=<code>` share link, auto-open the Collaborate
Expand Down Expand Up @@ -1702,7 +1703,7 @@
disposed = true;
unlisten?.();
};
}, [

Check warning on line 1706 in apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

View workflow job for this annotation

GitHub Actions / Build and test

React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array
clearDropMessageLater,
finishDrop,
addDroppedRasters,
Expand Down Expand Up @@ -1850,7 +1851,7 @@
clearDropMessageLater();
}
},
[

Check warning on line 1854 in apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

View workflow job for this annotation

GitHub Actions / Build and test

React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array
clearDropMessageLater,
finishDrop,
addDroppedRasters,
Expand Down Expand Up @@ -2142,6 +2143,7 @@
}}
onToggleThemeMode={onToggleThemeMode}
onOpenBasemapExtract={() => setBasemapExtractOpen(true)}
onAddComment={commentTool.toggleTool}
viewer={layoutOptions.viewer}
/>
</SectionErrorBoundary>
Expand All @@ -2168,6 +2170,8 @@
onActivateCommentTool={commentTool.toggleTool}
isCommentToolActive={commentTool.isActive}
onShowResolvedChange={setShowResolvedComments}
selectedCommentId={selectedCommentId}
onClearSelectedComment={() => setSelectedCommentId(null)}
/>,
commentsContentEl,
)
Expand Down Expand Up @@ -2305,7 +2309,10 @@
<RemoteCursorsOverlay mapControllerRef={mapControllerRef} />
<CommentMapOverlay
mapControllerRef={mapControllerRef}
onSelectComment={() => openRightPanel(COMMENTS_PANEL_ID)}
onSelectComment={(commentId) => {
setSelectedCommentId(commentId);
openRightPanel(COMMENTS_PANEL_ID);
}}
showResolved={showResolvedComments}
/>
<MapContextMenu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,8 @@ export function SettingsDialog({
closeRightPanel(BROWSER_PANEL_ID);
}
};
// Collapsed for the same reason as Browser above, and to match the state
// Comments registers itself in on mount.
const toggleCommentsPanel = (show: boolean) => {
if (show) {
openRightPanel(COMMENTS_PANEL_ID);
Expand Down
17 changes: 15 additions & 2 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ interface TopToolbarProps {
// Opens the Offline Basemap Extract panel, mounted in DesktopShell over the
// map so it can stay non-modal (the map is interactive for drawing a bbox).
onOpenBasemapExtract: () => void;
/** Activates the map tool for placing an anchored review comment. */
onAddComment: () => void;
viewer?: boolean;
}

Expand All @@ -204,6 +206,7 @@ export function TopToolbar({
onOpenProjectHistory,
onToggleThemeMode,
onOpenBasemapExtract,
onAddComment,
viewer = false,
}: TopToolbarProps) {
const { t, i18n } = useTranslation();
Expand Down Expand Up @@ -1331,6 +1334,15 @@ export function TopToolbar({
group: t("toolbar.commandGroup.addData"),
run: addLayer.duckdb,
},
{
id: "add.comment",
title: t("comments.addDialogTitle"),
group: t("toolbar.commandGroup.addData"),
keywords: "review note feedback",
icon: MessageSquare,
shortcut: { key: "c", shift: false },
Comment thread
giswqs marked this conversation as resolved.
run: onAddComment,
},
// Processing
{
id: "proc.whitebox",
Expand Down Expand Up @@ -1736,8 +1748,9 @@ export function TopToolbar({
// The shortcut layer is narrowed rather than switched off, because the View
// menu *does* stay visible in this mode: `view.*` is camera and theme work
// only, so dropping its keys would leave those items clickable but silently
// keyless. Every command carrying a `shortcut` is either `view.*` or
// `project.*`, so this is the whole authoring keyboard surface.
// keyless. Everything else carrying a `shortcut` authors the project
// (`project.*`, `add.comment`), so filtering to `view.*` drops exactly the
// authoring keyboard surface.
const shortcutCommands = useMemo(
() => (viewer ? commands.filter((command) => command.id.startsWith("view.")) : commands),
[commands, viewer],
Expand Down
Loading
Loading