This commit is contained in:
Timothy Jaeryang Baek 2026-10-30 16:43:24 +09:00
parent 9d1b4ef362
commit 91e8deee74
10 changed files with 606 additions and 78 deletions

View file

@ -108,10 +108,12 @@ async def mentions(
return result
async def list_comments(db, user, resource, resource_type, resource_id, parent_id, cursor, limit):
async def list_comments(db, user, resource, resource_type, resource_id, parent_id, cursor, limit, *, anchored=False):
query = select(Comment).where(
Comment.resource_type == resource_type, Comment.resource_id == resource_id, Comment.parent_id == parent_id
)
if anchored:
query = query.where(Comment.meta['anchor'].as_string().is_not(None), Comment.deleted_at.is_(None))
if cursor:
try:
timestamp, id = cursor.split(':', 1)
@ -138,11 +140,12 @@ async def list_resource_comments(
resource_id: str,
cursor: str | None = None,
limit: int = Query(20, ge=1, le=50),
anchored: bool = False,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
resource = await comment_resource(resource_type, resource_id, user, db, request=request)
result = await list_comments(db, user, resource, resource_type, resource_id, None, cursor, limit)
result = await list_comments(db, user, resource, resource_type, resource_id, None, cursor, limit, anchored=anchored)
result['total'] = await db.scalar(
select(func.count())
.select_from(Comment)

View file

@ -1,14 +1,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type CommentResource = {
resource_type:
| 'model'
| 'knowledge'
| 'prompt'
| 'skill'
| 'tool'
| 'note'
| 'chat';
resource_type: 'model' | 'knowledge' | 'prompt' | 'skill' | 'tool' | 'note' | 'chat';
resource_id: string;
};
export type CommentAnchor = {
@ -71,6 +64,7 @@ export async function commentsRequest<T>(
response.status,
typeof data.detail === 'string' ? data.detail : 'Unable to load comments'
);
if (method !== 'GET') window.dispatchEvent(new Event('comments:updated'));
return data;
}
export const resourceQuery = (resource: CommentResource) =>

View file

@ -2,10 +2,10 @@
import {
commentSurface,
contentHash,
commentSelectionDraft,
selectedComment
} from '$lib/components/comments/anchors';
import {
openComments,
config,
user,
artifactCode,
@ -333,7 +333,8 @@
<div
class="markdown-prose"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
resource_type: 'chat',
resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: 'content',
@ -388,7 +389,8 @@
<div
class="whitespace-pre-wrap text-[0.9375rem]"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
resource_type: 'chat',
resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: 'content',
@ -411,11 +413,7 @@
onComment={commentSelection
? () => {
if (!commentSelection) return;
const { options, anchor } = commentSelection;
openComments(
{ resource_type: options.resource_type, resource_id: options.resource_id },
{ target_type: options.target_type, target_id: options.target_id, anchor }
);
commentSelectionDraft.set(commentSelection);
window.getSelection()?.removeAllRanges();
closeFloatingButtons();
}

View file

@ -10,6 +10,8 @@
export let draftKey: string;
export let contextLabel = '';
export let quote = '';
export let scrollable = true;
export let embedded = false;
export let onClearContext: (() => void) | null = null;
export let initialContent = '';
export let submitLabel = 'Comment';
@ -88,35 +90,41 @@
: $settings?.chatDirection === 'LTR'
? 'ltr'
: 'auto'}
class="relative rounded-2xl border border-gray-100/50 dark:border-gray-850/50 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white/5 dark:bg-gray-500/5 ps-3 pb-2.5 {contextLabel ||
quote
? 'pt-1'
: 'pt-2.5'} {($settings?.highContrastMode ?? false)
class="relative {embedded
? ''
: `rounded-2xl border border-gray-100/50 dark:border-gray-850/50 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white/5 dark:bg-gray-500/5 ps-3 pb-2.5 ${contextLabel || quote ? 'pt-1' : 'pt-2.5'}`} {($settings?.highContrastMode ??
false)
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
: ''}"
>
{#if contextLabel || quote}
<div class="pb-2 pe-3 text-left w-full flex flex-col">
<div
class="flex items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<span class="truncate">{contextLabel}</span>{#if onClearContext}<button
type="button"
class="flex h-6 shrink-0 items-center justify-center p-0 hover:text-gray-700 dark:hover:text-gray-200"
aria-label={$i18n.t('Remove context')}
on:click={onClearContext}><XMark className="size-3.5" /></button
>{/if}
</div>
<div class="pb-2 {embedded ? '' : 'pe-3'} text-left w-full flex flex-col">
{#if contextLabel || onClearContext}
<div
class="flex items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<span class="truncate">{contextLabel}</span>{#if onClearContext}<button
type="button"
class="flex h-6 shrink-0 items-center justify-center p-0 hover:text-gray-700 dark:hover:text-gray-200"
aria-label={$i18n.t('Remove context')}
on:click={onClearContext}><XMark className="size-3.5" /></button
>{/if}
</div>
{/if}
{#if quote}<div
class="mb-1 mt-1 max-h-20 overflow-auto border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 whitespace-pre-wrap"
class="{embedded ? '' : 'mb-1 mt-1'} {scrollable
? 'max-h-20 overflow-auto'
: ''} border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 whitespace-pre-wrap break-words"
>
{quote}
</div>{/if}
</div>
{/if}
<div class={submitLabel === 'Save' ? 'pe-16' : 'pe-12'}>
<div class={embedded ? 'min-h-6 py-0.5 pe-9' : submitLabel === 'Save' ? 'pe-16' : 'pe-12'}>
<div
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full resize-none h-fit max-h-96 overflow-auto"
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full resize-none h-fit {scrollable
? 'max-h-96 overflow-auto'
: ''}"
>
<RichTextInput
id={`comment-input-${tempId}`}
@ -154,7 +162,7 @@
</div>
</div>
{#if error}<p role="alert" class="py-1 text-xs text-red-500">{error}</p>{/if}
<div class="absolute bottom-2 end-2.5 flex items-center">
<div class="absolute {embedded ? 'bottom-0 end-0' : 'bottom-2 end-2.5'} flex items-center">
<div class="flex items-center gap-2">
<button
type="button"

View file

@ -0,0 +1,488 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import tippy, { type Instance } from 'tippy.js';
import dayjs from '$lib/dayjs';
import { socket, user, commentsView, openComments } from '$lib/stores';
import { commentsRequest, resourceQuery, type ResourceComment } from '$lib/apis/comments';
import { isSavedChatId } from '$lib/utils/chatId';
import {
anchorRange,
getCommentSurfaces,
surfaceRevision,
commentSelectionDraft,
type CommentSurface
} from './anchors';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import CommentComposer from './CommentComposer.svelte';
import { scrollToComment } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
type LocatedComment = { comment: ResourceComment; range: Range; node: HTMLElement };
const i18n = getContext<any>('i18n');
let mounted = false;
let records: Record<string, ResourceComment[]> = {};
let located: LocatedComment[] = [];
let hovered: LocatedComment[] = [];
let preview: HTMLDivElement;
let popup: Instance;
let referenceRect: DOMRect;
let refreshTimer: ReturnType<typeof setTimeout>;
let connection: typeof $socket = null;
let composer: CommentComposer;
let previousDraft: typeof $commentSelectionDraft = null;
let postedComment: ResourceComment | null = null;
const requests = new Map<string, AbortController>();
const focusable = new Map<HTMLElement, { tabindex: string | null; description: string | null }>();
$: surfaces =
($surfaceRevision,
[...getCommentSurfaces()].filter(
([, options]) =>
options.resource_type === 'chat' &&
options.enabled !== false &&
isSavedChatId(options.resource_id)
));
$: resourceIds = [...new Set(surfaces.map(([, options]) => options.resource_id))];
$: if (mounted) syncResources(resourceIds);
$: if (mounted) rebuild(surfaces, records);
$: draftRange = $commentSelectionDraft ? selectionRange($commentSelectionDraft, surfaces) : null;
$: if (mounted) paint('comment-draft', draftRange ? [draftRange] : []);
$: if (mounted && $commentSelectionDraft !== previousDraft) {
previousDraft = $commentSelectionDraft;
if ($commentSelectionDraft) void startDraft($commentSelectionDraft);
else hide();
}
$: if (mounted)
paint(
'comment-active',
(hovered.length
? hovered
: located.filter(({ comment }) => comment.id === $commentsView?.comment_id)
).map(({ range }) => range)
);
const paint = (name: string, ranges: Range[]) => {
const highlights = (window.CSS as any)?.highlights;
const HighlightClass = (window as any).Highlight;
if (!highlights || !HighlightClass) return;
if (ranges.length) highlights.set(name, new HighlightClass(...ranges));
else highlights.delete(name);
};
const selectionRange = (
draft: NonNullable<typeof $commentSelectionDraft>,
entries: typeof surfaces
) => {
const entry = entries.find(
([, surface]) =>
surface.resource_id === draft.options.resource_id &&
surface.target_id === draft.options.target_id &&
surface.surface_id === draft.anchor.surface_id
);
return entry ? anchorRange(entry[0], draft.anchor) : null;
};
const startDraft = async (draft: NonNullable<typeof $commentSelectionDraft>) => {
hovered = [];
postedComment = null;
referenceRect = draft.rect;
await tick();
if (!mounted || $commentSelectionDraft !== draft) return;
popup.show();
await popup.popperInstance?.update();
composer?.focus();
};
const dismissDraft = () => {
commentSelectionDraft.set(null);
hide();
};
const submitted = async () => {
const created = postedComment;
if (!mounted || !created) return;
commentSelectionDraft.set(null);
await tick();
const item = located.find(({ comment }) => comment.id === created.id);
if (item) void reveal([item], item.range.getBoundingClientRect());
};
const load = async (id: string) => {
requests.get(id)?.abort();
const controller = new AbortController();
requests.set(id, controller);
let items: ResourceComment[] = [];
let cursor: string | null = null;
try {
do {
const page = await commentsRequest<{
items: ResourceComment[];
next_cursor: string | null;
}>(
`/?${resourceQuery({ resource_type: 'chat', resource_id: id })}&anchored=true&limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
'GET',
undefined,
controller.signal
);
if (controller.signal.aborted) return;
items = [
...items,
...page.items.filter((comment) => !comment.deleted_at && comment.meta?.anchor)
];
cursor = page.next_cursor;
} while (cursor);
if (!controller.signal.aborted) records = { ...records, [id]: items };
} catch {
if (!controller.signal.aborted) {
records = { ...records, [id]: [] };
hide();
}
}
};
const syncResources = (ids: string[]) => {
for (const [id, request] of requests) {
if (ids.includes(id)) continue;
request.abort();
requests.delete(id);
delete records[id];
}
for (const id of ids) if (!requests.has(id)) void load(id);
};
const refresh = () => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
for (const id of resourceIds) void load(id);
}, 150);
};
const changed = (resource: { resource_type: string; resource_id: string }) => {
if (resource.resource_type === 'chat' && resourceIds.includes(resource.resource_id)) refresh();
};
$: if (mounted && connection !== $socket) {
connection?.off('comments:changed', changed);
connection?.off('connect', refresh);
connection = $socket;
connection?.on('comments:changed', changed);
connection?.on('connect', refresh);
}
const restoreFocus = () => {
for (const [node, previous] of focusable) {
for (const [attribute, value] of [
['tabindex', previous.tabindex],
['aria-description', previous.description]
]) {
if (value === null) node.removeAttribute(attribute!);
else node.setAttribute(attribute!, value!);
}
}
focusable.clear();
};
const rebuild = (entries: [HTMLElement, CommentSurface][], comments: typeof records) => {
restoreFocus();
const next: LocatedComment[] = [];
for (const [node, surface] of entries) {
for (const comment of comments[surface.resource_id] ?? []) {
const anchor = comment.meta?.anchor;
if (
!anchor ||
comment.target_id !== surface.target_id ||
anchor.surface_id !== surface.surface_id
)
continue;
const range = anchorRange(node, anchor);
if (range) next.push({ comment, range, node });
}
}
located = next;
paint(
'comment-anchors',
next.map(({ range }) => range)
);
for (const node of new Set(next.map(({ node }) => node))) {
focusable.set(node, {
tabindex: node.getAttribute('tabindex'),
description: node.getAttribute('aria-description')
});
if (!node.hasAttribute('tabindex')) node.tabIndex = 0;
node.setAttribute('aria-description', $i18n.t('Comments'));
}
if (hovered.length) {
hovered = next.filter(({ comment }) =>
hovered.some((item) => item.comment.id === comment.id)
);
if (!hovered.length) hide();
}
};
const hide = () => {
popup?.hide();
hovered = [];
};
const leave = () => {
if (!$commentSelectionDraft) hide();
};
const reveal = async (items: LocatedComment[], rect: DOMRect) => {
referenceRect = rect;
hovered = items;
await tick();
if (!mounted || !hovered.length) return;
popup.show();
void popup.popperInstance?.update();
};
const atPoint = (event: MouseEvent) => {
if (!(event.target instanceof Node)) return [];
return located.filter(
({ node, range }) =>
node.contains(event.target as Node) &&
[...range.getClientRects()].some(
(rect) =>
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
)
);
};
const move = (event: MouseEvent) => {
if ($commentSelectionDraft) return;
if (popup?.popper.contains(event.target as Node)) return;
if (event.buttons || !window.getSelection()?.isCollapsed) {
hide();
return;
}
const items = atPoint(event);
if (!items.length) {
leave();
return;
}
if (items.length === hovered.length && items.every((item, index) => item === hovered[index])) {
return;
}
const rect = [...items[0].range.getClientRects()].find(
(rect) => event.clientY >= rect.top && event.clientY <= rect.bottom
)!;
void reveal(items, rect);
};
const open = async (comment: ResourceComment) => {
hide();
openComments(
{ resource_type: comment.resource_type, resource_id: comment.resource_id },
{ comment_id: comment.id }
);
await tick();
scrollToComment(comment.id);
};
const click = (event: MouseEvent) => {
if ($commentSelectionDraft) return;
if (
!window.getSelection()?.isCollapsed ||
(event.target as Element)?.closest('a, button, input, textarea, [contenteditable="true"]')
)
return;
const items = atPoint(event);
if (items.length === 1) open(items[0].comment);
};
const focus = (event: FocusEvent) => {
if ($commentSelectionDraft) return;
const items = located.filter(({ node }) => node === event.target);
if (items.length) void reveal(items, items[0].range.getBoundingClientRect());
else if (!popup?.popper.contains(event.target as Node) && !popup?.popper.matches(':hover'))
hide();
};
const keydown = (event: KeyboardEvent) => {
if ($commentSelectionDraft) {
if (event.key === 'Escape' && !document.getElementById('suggestions-container')) {
event.stopPropagation();
dismissDraft();
}
return;
}
if (event.key === 'Escape' && hovered.length) {
event.stopPropagation();
hide();
}
if (event.key === 'Enter' && focusable.has(event.target as HTMLElement)) {
const item = located.find(({ node }) => node === event.target);
if (item) {
event.preventDefault();
open(item.comment);
}
}
};
const scroll = (event: Event) => {
if ($commentSelectionDraft) {
void popup?.popperInstance?.update();
return;
}
if (!preview?.contains(event.target as Node)) hide();
};
onMount(() => {
referenceRect = new DOMRect();
popup = tippy(document.body, {
content: preview,
trigger: 'manual',
interactive: true,
placement: 'bottom-start',
getReferenceClientRect: () => draftRange?.getBoundingClientRect() ?? referenceRect,
appendTo: () => document.body,
arrow: false,
maxWidth: 'min(320px, calc(100vw - 24px))',
theme: 'comment-preview',
duration: 0,
hideOnClick: false,
offset: [0, 0]
});
mounted = true;
document.addEventListener('mousemove', move);
document.addEventListener('mouseleave', leave);
window.addEventListener('blur', leave);
document.addEventListener('click', click);
document.addEventListener('focusin', focus);
document.addEventListener('keydown', keydown);
document.addEventListener('scroll', scroll, true);
window.addEventListener('resize', hide);
window.addEventListener('focus', refresh);
window.addEventListener('comments:updated', refresh);
return () => {
mounted = false;
clearTimeout(refreshTimer);
for (const request of requests.values()) request.abort();
connection?.off('comments:changed', changed);
connection?.off('connect', refresh);
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseleave', leave);
window.removeEventListener('blur', leave);
document.removeEventListener('click', click);
document.removeEventListener('focusin', focus);
document.removeEventListener('keydown', keydown);
document.removeEventListener('scroll', scroll, true);
window.removeEventListener('resize', hide);
window.removeEventListener('focus', refresh);
window.removeEventListener('comments:updated', refresh);
restoreFocus();
paint('comment-anchors', []);
paint('comment-active', []);
paint('comment-draft', []);
popup.destroy();
};
});
</script>
<div class="hidden">
<div
bind:this={preview}
class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 text-gray-800 dark:text-gray-200 shadow-lg"
>
{#if $commentSelectionDraft}
{@const draft = $commentSelectionDraft}
<div class="w-80 max-w-[calc(100vw-24px)] px-3 py-2">
<div class="mb-2.5 flex min-w-0 items-center gap-2">
<ProfileImage
src={$user?.id ? `${WEBUI_API_BASE_URL}/users/${$user.id}/profile/image` : undefined}
className="-ml-1 size-6 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate text-[0.8125rem] text-gray-900 dark:text-gray-100"
>{$user?.name ?? $i18n.t('Unknown User')}</span
>
<button
type="button"
aria-label={$i18n.t('Close')}
class="ms-auto shrink-0 p-0 text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100"
on:click={dismissDraft}><XMark className="size-3.5" /></button
>
</div>
<div
class="my-1 border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words"
>
{draft.anchor.quote}
</div>
{#key `${draft.options.resource_id}:${draft.options.target_id}:${draft.anchor.surface_id}:${draft.anchor.start}:${draft.anchor.end}`}
<CommentComposer
bind:this={composer}
resource={draft.options}
scrollable={false}
embedded={true}
draftKey={`${$user?.id}:${resourceQuery(draft.options)}:${draft.options.target_id}:${draft.anchor.surface_id}:${draft.anchor.source}:${draft.anchor.start}:${draft.anchor.end}`}
onCancel={dismissDraft}
onSubmitted={submitted}
onSubmit={async (content, temp_id) => {
const created = await commentsRequest<ResourceComment>('/', 'POST', {
resource_type: draft.options.resource_type,
resource_id: draft.options.resource_id,
target_type: draft.options.target_type,
target_id: draft.options.target_id,
anchor: draft.anchor,
content,
temp_id
});
if (!mounted || $commentSelectionDraft !== draft) return;
postedComment = created;
records = {
...records,
[created.resource_id]: [
...(records[created.resource_id] ?? []).filter((item) => item.id !== created.id),
created
]
};
}}
/>
{/key}
</div>
{/if}
{#each hovered as { comment } (comment.id)}
<div class="px-3 py-2">
<div class="mb-2.5 flex min-w-0 items-center gap-2">
<ProfileImage
src={comment.user?.id
? `${WEBUI_API_BASE_URL}/users/${comment.user.id}/profile/image`
: undefined}
className="-ml-1 size-6 shrink-0 rounded-full!"
/>
<span class="min-w-0 truncate text-[0.8125rem] text-gray-900 dark:text-gray-100"
>{comment.user?.name ?? $i18n.t('Deleted user')}</span
>
<span class="ms-auto shrink-0 text-xs text-gray-400 dark:text-gray-500"
>{dayjs(comment.created_at / 1e6).fromNow()}</span
>
</div>
{#if comment.meta?.anchor}
<div
class="my-1 border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words"
>
{comment.meta.anchor.quote}
</div>
{/if}
<div class="mt-1 text-[0.8125rem] text-gray-600 dark:text-gray-300 break-words">
<Markdown
id={`comment-preview-${comment.id}`}
content={comment.content}
done={true}
allowEmbeds={false}
allowHtml={false}
compactPreview={true}
/>
</div>
<button
type="button"
class="mt-2 p-0 text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
on:click={() => open(comment)}>{$i18n.t('View comment')}</button
>
</div>
{/each}
</div>
</div>
<style>
:global(::highlight(comment-anchors)) {
background-color: #fbbf2433;
}
:global(::highlight(comment-active)) {
background-color: #fbbf2466;
}
:global(::highlight(comment-draft)) {
background-color: #fbbf2466;
}
:global(.tippy-box[data-theme~='comment-preview']) {
background: transparent;
color: inherit;
}
:global(.tippy-box[data-theme~='comment-preview'] > .tippy-content) {
/* Keep the gap to the passage inside the popup's hover area. */
padding: 6px 0;
}
</style>

View file

@ -3,7 +3,7 @@
import { commentsRequest, type ResourceComment } from '$lib/apis/comments';
import CommentItem from './CommentItem.svelte';
import CommentReplyComposer from './CommentReplyComposer.svelte';
import { groupReplies } from '$lib/utils';
import { groupReplies, scrollToComment } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
export let parent: ResourceComment;
export let replyTo: ResourceComment | null;
@ -71,7 +71,7 @@
if (linkedCommentId && revealedId !== linkedCommentId) {
revealedId = linkedCommentId;
await tick();
document.getElementById(`comment-${linkedCommentId}`)?.scrollIntoView({ block: 'nearest' });
if (alive && request === version) scrollToComment(linkedCommentId);
}
} catch (e) {
if (alive && request === version) onError(e);

View file

@ -17,7 +17,8 @@
import Modal from '$lib/components/common/Modal.svelte';
import Annotation from '$lib/components/icons/Annotation.svelte';
import CommentsPanel from './CommentsPanel.svelte';
import { selectedComment } from './anchors';
import CommentHighlights from './CommentHighlights.svelte';
import { selectedComment, commentSelectionDraft } from './anchors';
import { toast } from 'svelte-sonner';
const i18n = getContext<any>('i18n');
let selection: ReturnType<typeof selectedComment> = null;
@ -30,13 +31,19 @@
navigation += 1;
deepLink = '';
commentsView.set(null);
commentSelectionDraft.set(null);
selection = null;
}
$: commentsAllowed = !!($config?.features?.enable_comments && $user && !$page.url.pathname.startsWith('/s/'));
$: commentsAllowed = !!(
$config?.features?.enable_comments &&
$user &&
!$page.url.pathname.startsWith('/s/')
);
let preparing = false;
$: if (($user?.id ?? null) !== account) {
account = $user?.id ?? null;
commentsView.set(null);
commentSelectionDraft.set(null);
selection = null;
tick().then(() => commentDrafts.clear());
}
@ -59,17 +66,12 @@
toast.error(e instanceof Error ? e.message : String(e));
}
};
$: if (
commentsAllowed &&
$page.url.searchParams.get('comment') &&
deepLink !== $page.url.href
) {
$: if (commentsAllowed && $page.url.searchParams.get('comment') && deepLink !== $page.url.href) {
deepLink = $page.url.href;
loadLink($page.url.searchParams.get('comment')!);
}
const select = () => {
if (!preparing)
selection = commentsAllowed ? selectedComment() : null;
if (!preparing) selection = commentsAllowed ? selectedComment() : null;
};
const commentSelection = async () => {
const selected = selection;
@ -80,17 +82,19 @@
try {
await selected.options.prepare?.();
if (version !== navigation || accountId !== account) return;
openComments(
{
resource_type: selected.options.resource_type,
resource_id: selected.options.resource_id
},
{
target_type: selected.options.target_type,
target_id: selected.options.target_id,
anchor: selected.anchor
}
);
if (selected.options.resource_type === 'chat') commentSelectionDraft.set(selected);
else
openComments(
{
resource_type: selected.options.resource_type,
resource_id: selected.options.resource_id
},
{
target_type: selected.options.target_type,
target_id: selected.options.target_id,
anchor: selected.anchor
}
);
window.getSelection()?.removeAllRanges();
selection = null;
} catch (e) {
@ -119,6 +123,7 @@
on:scroll|capture={() => (selection = null)}
/>
{#if commentsAllowed}
{#key account}<CommentHighlights />{/key}
{#if selection && !selection.options.toolbar_id}
<button
type="button"

View file

@ -16,6 +16,7 @@
import CommentReplies from './CommentReplies.svelte';
import CommentComposer from './CommentComposer.svelte';
import CommentReplyComposer from './CommentReplyComposer.svelte';
import { scrollToComment } from '$lib/utils';
export let view: CommentsView;
export let embedded = false;
export let onCountChange: (count: number) => void = () => {};
@ -150,8 +151,7 @@
if (alive) loading = false;
}
await tick();
if (alive && linkedCommentId === linkedRootId)
document.getElementById(`comment-${linkedRootId}`)?.scrollIntoView({ block: 'nearest' });
if (alive && linkedRootId && linkedCommentId === linkedRootId) scrollToComment(linkedRootId);
};
const more = async () => {
if (!resource || loadingMore) return;

View file

@ -1,5 +1,6 @@
import { isSavedChatId } from '$lib/utils/chatId';
import { sha256 } from 'js-sha256';
import { writable } from 'svelte/store';
import type { CommentAnchor, CommentResource, CommentTarget } from '$lib/apis/comments';
export type CommentSurface = CommentResource &
@ -21,14 +22,32 @@ export function noteCommentText(content: { json?: unknown; md?: string }) {
return content.json && typeof content.json === 'object' ? text(content.json) : (content.md ?? '');
}
const surfaces = new Map<HTMLElement, CommentSurface>();
export const surfaceRevision = writable(0);
export const commentSelectionDraft = writable<ReturnType<typeof selectedComment>>(null);
export const getCommentSurfaces = () => surfaces;
let pendingSurfaceUpdate = false;
const surfaceChanged = () => {
if (pendingSurfaceUpdate) return;
pendingSurfaceUpdate = true;
queueMicrotask(() => {
pendingSurfaceUpdate = false;
surfaceRevision.update((value) => value + 1);
});
};
export function commentSurface(node: HTMLElement, options: CommentSurface) {
surfaces.set(node, options);
surfaceChanged();
const observer = new MutationObserver(surfaceChanged);
observer.observe(node, { childList: true, characterData: true, subtree: true });
return {
update(value: CommentSurface) {
surfaces.set(node, value);
surfaceChanged();
},
destroy() {
observer.disconnect();
surfaces.delete(node);
surfaceChanged();
}
};
}
@ -85,8 +104,7 @@ export function selectedComment() {
for (const [node, options] of surfaces) {
if (
options.enabled === false ||
(options.resource_type === 'chat' &&
!isSavedChatId(options.resource_id)) ||
(options.resource_type === 'chat' && !isSavedChatId(options.resource_id)) ||
!options.resource_id ||
!node.contains(range.startContainer) ||
!node.contains(range.endContainer)
@ -132,6 +150,25 @@ export function selectedComment() {
}
return null;
}
export function anchorRange(node: HTMLElement, anchor: CommentAnchor): Range | null {
const { nodes, text } = surfaceText(node);
const position = anchorPosition(text, anchor);
if (!position) return null;
const range = document.createRange();
const start = [...text].slice(0, position[0]).join('').length;
const end = [...text].slice(0, position[1]).join('').length;
let offset = 0;
for (const n of nodes) {
if (start >= offset && start < offset + n.length) range.setStart(n, start - offset);
if (end > offset && end <= offset + n.length) {
range.setEnd(n, end - offset);
return range;
}
offset += n.length;
}
return null;
}
export function highlightAnchor(
resource: CommentResource,
targetId: string,
@ -145,21 +182,8 @@ export function highlightAnchor(
options.surface_id !== anchor.surface_id
)
continue;
const { nodes, text } = surfaceText(node);
const position = anchorPosition(text, anchor);
if (!position) return false;
const range = document.createRange();
let start = [...text].slice(0, position[0]).join('').length;
let end = [...text].slice(0, position[1]).join('').length;
let offset = 0;
for (const n of nodes) {
if (start >= offset && start <= offset + n.length) range.setStart(n, start - offset);
if (end >= offset && end <= offset + n.length) {
range.setEnd(n, end - offset);
break;
}
offset += n.length;
}
const range = anchorRange(node, anchor);
if (!range) return false;
const highlights = (CSS as any).highlights;
const HighlightClass = (window as any).Highlight;
if (highlights && HighlightClass) {

View file

@ -2519,6 +2519,14 @@ export function groupReplies(items: ResourceComment[], parentId: string) {
return children;
}
export function scrollToComment(id: string) {
const element = document.getElementById(`comment-${id}`);
element?.scrollIntoView({ block: 'center' });
element?.animate([{ backgroundColor: '#a5a5c544' }, { backgroundColor: 'transparent' }], {
duration: 2200
});
}
export function commentLink(
comment: Pick<
ResourceComment,