Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
84 changes: 70 additions & 14 deletions apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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,6 +63,8 @@ export function CommentsPanel({
onActivateCommentTool,
isCommentToolActive,
onShowResolvedChange,
selectedCommentId,
onClearSelectedComment,
}: CommentsPanelProps) {
const comments = useAppStore((s) => s.comments);
const replyToComment = useAppStore((s) => s.replyToComment);
Expand All @@ -67,6 +73,36 @@ export function CommentsPanel({
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 +148,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 +263,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 +340,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="Copy session URL"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
>
<Copy className="h-3 w-3 me-1" />
{copied ? "Copied" : "Copy"}
Expand Down Expand Up @@ -392,7 +433,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 +470,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 @@ -429,7 +429,6 @@ export function SettingsDialog({
const toggleCommentsPanel = (show: boolean) => {
if (show) {
openRightPanel(COMMENTS_PANEL_ID);
collapseRightPanel(COMMENTS_PANEL_ID);
} else {
closeRightPanel(COMMENTS_PANEL_ID);
}
Comment thread
giswqs marked this conversation as resolved.
Expand Down
12 changes: 12 additions & 0 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
27 changes: 23 additions & 4 deletions apps/geolibre-desktop/src/hooks/useCollaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type { RefObject } from "react";
import type { MapController } from "@geolibre/map";
import type { Map as MapLibreMap } from "maplibre-gl";
import i18n from "../i18n";
import { buildProjectEgressSnapshot } from "../lib/build-project-snapshot";
import {
buildCollaborationSnapshot,
buildProjectEgressSnapshot,
} from "../lib/build-project-snapshot";
import { projectChanged } from "../lib/project-broadcast-changed";
import {
CollabConnection,
Expand Down Expand Up @@ -49,6 +52,7 @@ export function useCollaboration(
const teardownRef = useRef<(() => void) | null>(null);
const lastContentRef = useRef<string | null>(null);
const revRef = useRef(0);
const snapshotRequestRef = useRef(0);
const selfIdRef = useRef<string | null>(null);
const syncPausedRef = useRef(false);
const restoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All @@ -74,9 +78,13 @@ export function useCollaboration(
return self?.editOverride ?? c.mode === "co-edit";
};

const sendSnapshot = (): void => {
const sendSnapshot = async (): Promise<void> => {
if (!canEdit() || syncPausedRef.current) return;
const project = buildProjectEgressSnapshot(mapControllerRef);
const request = ++snapshotRequestRef.current;
const project = await buildCollaborationSnapshot(mapControllerRef);
Comment thread
giswqs marked this conversation as resolved.
Outdated
// Materializing control-managed vector data is asynchronous. Discard an
// older result if a newer broadcast started while it was being read.
if (request !== snapshotRequestRef.current || !canEdit() || syncPausedRef.current) return;
Comment thread
giswqs marked this conversation as resolved.
const content = serializeProject(project);
if (content === lastContentRef.current) return;
lastContentRef.current = content;
Expand Down Expand Up @@ -134,6 +142,13 @@ export function useCollaboration(
});
}
if (message.snapshot) applyRemoteSnapshot(message.snapshot, true);
// Guests follow the host by default. Apply the host's latest presence
// immediately instead of waiting for their next moveend event.
if (message.role === "guest" && useAppStore.getState().collaboration.followHost) {
const host = message.participants.find((participant) => participant.role === "host");
const hostView = host ? message.presence[host.clientId]?.view : null;
if (hostView) mapControllerRef.current?.applyView(hostView);
}
const pending = pendingConnectRef.current;
pendingConnectRef.current = null;
pending?.resolve();
Expand Down Expand Up @@ -211,7 +226,7 @@ export function useCollaboration(
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
debounce = null;
sendSnapshot();
void sendSnapshot();
}, SNAPSHOT_DEBOUNCE_MS);
};

Expand Down Expand Up @@ -293,6 +308,9 @@ export function useCollaboration(
mode: "co-edit",
clientId: selfIdRef.current,
participants: [selfParticipant],
// A participant joining an existing session normally wants to arrive at
// and stay with the host's viewport. They can turn this off at any time.
followHost: !hostToken,
error: null,
});

Expand Down Expand Up @@ -329,6 +347,7 @@ export function useCollaboration(
};

const disconnect = (): void => {
snapshotRequestRef.current += 1;
teardownRef.current?.();
teardownRef.current = null;
if (pendingConnectRef.current) {
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
secondaryMapViews: state.secondaryMapViews,
primaryMapLabel: state.primaryMapLabel,
styleLibrary: state.projectStyleLibrary,
comments: state.comments,
metadata: state.metadata,
});
return {
Expand Down
8 changes: 5 additions & 3 deletions apps/geolibre-desktop/src/hooks/useRegisterCommentsPanel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { registerRightPanel } from "@geolibre/plugins";
import { collapseRightPanel, openRightPanel, registerRightPanel } from "@geolibre/plugins";
import { useEffect } from "react";
import i18n from "../i18n";

Expand All @@ -9,8 +9,8 @@ export const COMMENTS_PANEL_ID = "comments";
* Registers the Comments panel as a dockable right panel sharing the Style (right)
* sidebar's rail (`replace-style`).
*
* Unlike the Browser panel, Comments is opt-in: opening it on mount would
* displace Browser because dockable panels share one active registry slot.
* Comments is enabled by default but collapsed onto the Style rail, so it is
* discoverable without taking map space.
*/
export function useRegisterCommentsPanel(): void {
useEffect(() => {
Expand All @@ -22,6 +22,8 @@ export function useRegisterCommentsPanel(): void {
dock: "replace-style",
render: () => {},
});
openRightPanel(COMMENTS_PANEL_ID);
collapseRightPanel(COMMENTS_PANEL_ID);
return dispose;
}, []);
}
Loading
Loading