Add copy link and highlight to comments

Opening a comment link expands the revision the comment is in, even if
the link is opened while already on the page, and scrolls to it.

This also fixes scrolling to URL fragments that aren't valid CSS
selectors, such as comment ids starting with a digit.
This commit is contained in:
Brandon Oxendine
2026-10-19 18:02:19 +02:00
committed by Rūdolfs Ošiņš
parent 722a83af54
commit 095b4f2fc0
7 changed files with 184 additions and 5 deletions
+59 -1
View File
@@ -1,12 +1,17 @@
<script lang="ts">
import type { BaseUrl, Comment } from "@http-client";
import debounce from "lodash/debounce";
import { onMount } from "svelte";
import * as utils from "@app/lib/utils";
type Side = "left" | "right";
type SelectionAnchor = { side: Side; lineNumber: number };
type SelectionRange = { start: SelectionAnchor; end?: SelectionAnchor };
import Icon from "@app/components/Icon.svelte";
import IconButton from "@app/components/IconButton.svelte";
import Id from "@app/components/Id.svelte";
import Markdown from "@app/components/Markdown.svelte";
import NodeId from "@app/components/NodeId.svelte";
@@ -40,6 +45,26 @@
}
}
$: selectionRange = rangeAnchorsFromCodeLocation(location);
let linkCopied = false;
let targeted = false;
const restoreLinkIcon = debounce(() => {
linkCopied = false;
}, 1000);
async function copyLink() {
const { origin, pathname, search } = window.location;
await utils.toClipboard(`${origin}${pathname}${search}#${id}`);
linkCopied = true;
restoreLinkIcon();
}
function updateTargeted() {
targeted = utils.fragmentId(window.location.hash) === id;
}
onMount(updateTargeted);
</script>
<style>
@@ -48,6 +73,23 @@
flex-direction: column;
padding: 0.5rem 0;
gap: 0.5rem;
border-radius: var(--border-radius-sm);
scroll-margin-top: 5rem;
}
.card.targeted {
box-shadow: 0 0 0 2px var(--color-border-brand);
}
.copy-link {
margin-left: auto;
}
@media (hover: hover) {
.copy-link {
opacity: 0;
}
.card:hover .copy-link,
.copy-link:has(:global(:focus-visible)) {
opacity: 1;
}
}
.card-header {
display: flex;
@@ -118,7 +160,14 @@
}
</style>
<div class="card" class:card-empty-body={!body} {id} class:reply={isReply}>
<svelte:window on:hashchange={updateTargeted} />
<div
class="card"
class:card-empty-body={!body}
class:targeted
{id}
class:reply={isReply}>
<div style:position="relative">
{#if isReply}
<div class="reply-dot"></div>
@@ -163,6 +212,15 @@
• edited
</div>
{/if}
<div class="copy-link">
<IconButton
title={linkCopied ? "Link copied" : "Copy link to comment"}
ariaLabel="Copy link to comment"
stylePadding="0.25rem"
on:click={copyLink}>
<Icon name={linkCopied ? "checkmark" : "link"} />
</IconButton>
</div>
</div>
</div>
+3 -2
View File
@@ -219,8 +219,9 @@ async function navigate(
window.scrollTo(0, restoreScrollY ?? 0);
return;
}
if (hash.length > 1) {
const el = document.querySelector(hash);
const fragment = utils.fragmentId(hash);
if (fragment !== undefined) {
const el = document.getElementById(fragment);
if (el) {
const top =
el.getBoundingClientRect().top +
+12
View File
@@ -246,6 +246,18 @@ export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
if (lineElement) lineElement.scrollIntoView(options);
}
export function fragmentId(hash: string): string | undefined {
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
if (fragment === "") {
return undefined;
}
try {
return decodeURIComponent(fragment);
} catch {
return undefined;
}
}
// Check whether the given path has a markdown file extension.
export function isMarkdownPath(path: string): boolean {
return /\.(md|mkd|markdown)$/i.test(path);
+38 -2
View File
@@ -13,7 +13,7 @@
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@http-client";
import { cachedGetDiff } from "@app/views/repos/router";
import { onMount } from "svelte";
import { onMount, tick } from "svelte";
import CobCommitTeaser from "@app/views/repos/Cob/CobCommitTeaser.svelte";
import CommentComponent from "@app/components/Comment.svelte";
@@ -55,7 +55,41 @@
export let previousRevOid: string | undefined = undefined;
export let first: boolean;
let expanded = initiallyExpanded;
function timelinesContain(commentId: string | undefined): boolean {
if (commentId === undefined) {
return false;
}
const inThread = (thread: { root: Comment; replies: Comment[] }) =>
thread.root.id === commentId ||
thread.replies.some(reply => reply.id === commentId);
return timelines.some(element => {
switch (element.type) {
case "thread":
return inThread(element.inner);
case "review": {
const review = element.inner[1];
return review.id === commentId || review.threads.some(inThread);
}
case "merge":
return false;
}
});
}
let expanded =
initiallyExpanded ||
timelinesContain(utils.fragmentId(window.location.hash));
async function expandToHash() {
const commentId = utils.fragmentId(window.location.hash);
if (expanded || commentId === undefined || !timelinesContain(commentId)) {
return;
}
expanded = true;
await tick();
utils.scrollIntoView(commentId);
}
const api = new HttpdClient(baseUrl);
const lastEdit = revisionEdits.at(-1);
@@ -273,6 +307,8 @@
}
</style>
<svelte:window on:hashchange={expandToHash} />
<div class="revision" style:margin-bottom={expanded ? "2rem" : "0.5rem"}>
<div class="revision-box" class:expanded>
<div class="revision-header">