feat(#73): deep-link to specific comments via ?commentId= query param
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m55s
CI / Backend Unit Tests (push) Successful in 2m10s
CI / E2E Tests (push) Failing after 2h23m30s
CI / Unit & Component Tests (pull_request) Failing after 2m3s
CI / Backend Unit Tests (pull_request) Successful in 2m20s
CI / E2E Tests (pull_request) Failing after 2h3m35s
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m55s
CI / Backend Unit Tests (push) Successful in 2m10s
CI / E2E Tests (push) Failing after 2h23m30s
CI / Unit & Component Tests (pull_request) Failing after 2m3s
CI / Backend Unit Tests (pull_request) Successful in 2m20s
CI / E2E Tests (pull_request) Failing after 2h3m35s
- +page.svelte: read ?commentId= from URL; on mount, if present open bottom panel to discussion tab - CommentThread: add targetCommentId prop — scrolls to comment on mount (scrollIntoView), applies ring highlight, removes highlight on first user interaction (click/keydown/scroll) - CommentThread: add data-comment-id attributes to thread root and reply divs - PanelDiscussion / DocumentBottomPanel: thread targetCommentId prop through the chain Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ type Props = {
|
|||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
onCountChange?: (count: number) => void;
|
onCountChange?: (count: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,10 +26,12 @@ let {
|
|||||||
canComment,
|
canComment,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canAdmin,
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
onCountChange
|
onCountChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
|
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
|
||||||
let newText: string = $state('');
|
let newText: string = $state('');
|
||||||
let replyingTo: string | null = $state(null);
|
let replyingTo: string | null = $state(null);
|
||||||
let replyText: string = $state('');
|
let replyText: string = $state('');
|
||||||
@@ -184,6 +187,25 @@ onMount(() => {
|
|||||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||||
onCountChange?.(total);
|
onCountChange?.(total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetCommentId) {
|
||||||
|
// Scroll to target after a tick so the DOM is settled
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Remove highlight on first user interaction
|
||||||
|
const clearHighlight = () => {
|
||||||
|
highlightedCommentId = null;
|
||||||
|
document.removeEventListener('click', clearHighlight, true);
|
||||||
|
document.removeEventListener('keydown', clearHighlight, true);
|
||||||
|
document.removeEventListener('scroll', clearHighlight, true);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', clearHighlight, true);
|
||||||
|
document.addEventListener('keydown', clearHighlight, true);
|
||||||
|
document.addEventListener('scroll', clearHighlight, true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -287,13 +309,23 @@ onMount(() => {
|
|||||||
{#each comments as thread, ti (thread.id)}
|
{#each comments as thread, ti (thread.id)}
|
||||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<!-- Root comment -->
|
<!-- Root comment -->
|
||||||
<div>
|
<div
|
||||||
|
data-comment-id={thread.id}
|
||||||
|
class={highlightedCommentId === thread.id
|
||||||
|
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
{#each thread.replies as reply, ri (reply.id)}
|
{#each thread.replies as reply, ri (reply.id)}
|
||||||
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
|
<div
|
||||||
|
data-comment-id={reply.id}
|
||||||
|
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
|
||||||
|
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Props = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
activeTab: DocumentPanelTab;
|
activeTab: DocumentPanelTab;
|
||||||
|
targetCommentId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,7 +39,8 @@ let {
|
|||||||
canAdmin,
|
canAdmin,
|
||||||
open = $bindable(),
|
open = $bindable(),
|
||||||
height = $bindable(),
|
height = $bindable(),
|
||||||
activeTab = $bindable()
|
activeTab = $bindable(),
|
||||||
|
targetCommentId = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||||
@@ -180,6 +182,7 @@ function handleCountChange(count: number) {
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
onCountChange={handleCountChange}
|
onCountChange={handleCountChange}
|
||||||
/>
|
/>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ type Props = {
|
|||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
onCountChange?: (count: number) => void;
|
onCountChange?: (count: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
|
let {
|
||||||
$props();
|
documentId,
|
||||||
|
initialComments,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
|
onCountChange
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
@@ -22,6 +30,7 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountC
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
onCountChange={onCountChange}
|
onCountChange={onCountChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||||
@@ -8,6 +9,8 @@ import type { DocumentPanelTab } from '$lib/types';
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
const canAdmin = $derived(
|
const canAdmin = $derived(
|
||||||
@@ -92,7 +95,11 @@ onMount(() => {
|
|||||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedOpen === 'true') {
|
if (targetCommentId) {
|
||||||
|
// Deep-link: always open discussion tab regardless of saved state
|
||||||
|
panelOpen = true;
|
||||||
|
activeTab = 'discussion';
|
||||||
|
} else if (savedOpen === 'true') {
|
||||||
panelOpen = true;
|
panelOpen = true;
|
||||||
} else if (savedOpen === null && !doc?.filePath) {
|
} else if (savedOpen === null && !doc?.filePath) {
|
||||||
// No prior state and no file — open to metadata so the panel is immediately useful.
|
// No prior state and no file — open to metadata so the panel is immediately useful.
|
||||||
@@ -175,6 +182,7 @@ $effect(() => {
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
bind:open={panelOpen}
|
bind:open={panelOpen}
|
||||||
bind:height={panelHeight}
|
bind:height={panelHeight}
|
||||||
bind:activeTab={activeTab}
|
bind:activeTab={activeTab}
|
||||||
|
|||||||
Reference in New Issue
Block a user