refactor: reorganize task completion notifications in settings
- Moved the Task Completion Notifications section within the NotificationsSection component for improved structure and clarity. - Updated the UI elements related to task completion notifications, ensuring consistent styling and functionality. - Enhanced the overall user experience by maintaining clear access to notification settings.
This commit is contained in:
parent
f5efa17b1a
commit
edddf526db
6 changed files with 318 additions and 64 deletions
|
|
@ -147,37 +147,6 @@ export const NotificationsSection = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Task Completion Notifications */}
|
|
||||||
<SettingsSectionHeader
|
|
||||||
title="Task Completion Notifications"
|
|
||||||
icon={<PartyPopper className="size-3.5" />}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="mb-4 rounded-lg border p-4"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
|
||||||
badges. Works on macOS, Linux, and Windows.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
|
||||||
}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-border-emphasis)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="size-3.5" />
|
|
||||||
Install claude-notifications-go plugin
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notification Settings */}
|
{/* Notification Settings */}
|
||||||
<SettingsSectionHeader title="Notification Settings" icon={<Bell className="size-3.5" />} />
|
<SettingsSectionHeader title="Notification Settings" icon={<Bell className="size-3.5" />} />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
|
|
@ -427,6 +396,37 @@ export const NotificationsSection = ({
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
dropUp
|
dropUp
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Task Completion Notifications */}
|
||||||
|
<SettingsSectionHeader
|
||||||
|
title="Task Completion Notifications"
|
||||||
|
icon={<PartyPopper className="size-3.5" />}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mb-4 rounded-lg border p-4"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
||||||
|
badges. Works on macOS, Linux, and Windows.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-border-emphasis)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
Install claude-notifications-go plugin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ interface TaskCommentsSectionProps {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
|
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
|
||||||
unreadCommentIds?: Set<string>;
|
unreadCommentIds?: Set<string>;
|
||||||
|
/**
|
||||||
|
* Ref callback factory from useViewportCommentRead.
|
||||||
|
* When provided, each comment element is registered for viewport-based read tracking.
|
||||||
|
*/
|
||||||
|
registerCommentForViewport?: (timestampMs: number) => (el: HTMLElement | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskCommentsSection = ({
|
export const TaskCommentsSection = ({
|
||||||
|
|
@ -79,6 +84,7 @@ export const TaskCommentsSection = ({
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
unreadCommentIds,
|
unreadCommentIds,
|
||||||
|
registerCommentForViewport,
|
||||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||||
const addingComment = useStore((s) => s.addingComment);
|
const addingComment = useStore((s) => s.addingComment);
|
||||||
|
|
@ -209,6 +215,11 @@ export const TaskCommentsSection = ({
|
||||||
{visibleComments.map((comment, index) => (
|
{visibleComments.map((comment, index) => (
|
||||||
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||||
<div
|
<div
|
||||||
|
ref={
|
||||||
|
registerCommentForViewport
|
||||||
|
? registerCommentForViewport(new Date(comment.createdAt).getTime())
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={[
|
className={[
|
||||||
'group min-w-0 overflow-hidden px-4 py-2.5',
|
'group min-w-0 overflow-hidden px-4 py-2.5',
|
||||||
comment.type === 'review_approved'
|
comment.type === 'review_approved'
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
|
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
|
import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead';
|
||||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import {
|
import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
|
|
@ -44,10 +45,14 @@ import {
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import {
|
||||||
|
deriveTaskDisplayId,
|
||||||
|
formatTaskDisplayLabel,
|
||||||
|
taskMatchesRef,
|
||||||
|
} from '@shared/utils/taskIdentity';
|
||||||
import { format, formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
|
|
@ -217,6 +222,9 @@ export const TaskDetailDialog = ({
|
||||||
const setLightboxOpen = useCallback((isOpen: boolean) => {
|
const setLightboxOpen = useCallback((isOpen: boolean) => {
|
||||||
lightboxOpenRef.current = isOpen;
|
lightboxOpenRef.current = isOpen;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Ref for the scrollable DialogContent — needed as IO root for viewport-based read tracking.
|
||||||
|
const dialogContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const handleReply = useCallback(
|
const handleReply = useCallback(
|
||||||
(author: string, text: string) => {
|
(author: string, text: string) => {
|
||||||
if (currentTask) setReplyTo({ taskId: currentTask.id, author, text });
|
if (currentTask) setReplyTo({ taskId: currentTask.id, author, text });
|
||||||
|
|
@ -225,11 +233,6 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
const clearReply = useCallback(() => setReplyTo(null), []);
|
const clearReply = useCallback(() => setReplyTo(null), []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setReplyTo(null);
|
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const effectiveReplyTo =
|
const effectiveReplyTo =
|
||||||
replyTo && replyTo.taskId === currentTask?.id
|
replyTo && replyTo.taskId === currentTask?.id
|
||||||
? { author: replyTo.author, text: replyTo.text }
|
? { author: replyTo.author, text: replyTo.text }
|
||||||
|
|
@ -259,6 +262,19 @@ export const TaskDetailDialog = ({
|
||||||
unreadSnapshotRef.current = unread;
|
unreadSnapshotRef.current = unread;
|
||||||
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Viewport-based comment read tracking (replaces mark-all-on-mount)
|
||||||
|
const { registerComment, flush: flushCommentRead } = useViewportCommentRead({
|
||||||
|
teamName,
|
||||||
|
taskId: currentTask?.id ?? '',
|
||||||
|
scrollContainerRef: dialogContentRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
flushCommentRead();
|
||||||
|
setReplyTo(null);
|
||||||
|
onClose();
|
||||||
|
}, [onClose, flushCommentRead]);
|
||||||
|
|
||||||
// Collect image attachments from comments for the Attachments section
|
// Collect image attachments from comments for the Attachments section
|
||||||
const commentImageAttachments = useMemo(() => {
|
const commentImageAttachments = useMemo(() => {
|
||||||
const comments = currentTask?.comments ?? [];
|
const comments = currentTask?.comments ?? [];
|
||||||
|
|
@ -413,8 +429,19 @@ export const TaskDetailDialog = ({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDependencyClick = (taskId: string): void => {
|
const handleDependencyClick = (taskId: string): void => {
|
||||||
|
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
|
||||||
|
// since kanban cards use the full UUID in data-task-id.
|
||||||
|
let resolvedId = taskId;
|
||||||
|
if (!taskMap.has(taskId)) {
|
||||||
|
for (const [fullId, t] of taskMap) {
|
||||||
|
if (taskMatchesRef(t, taskId)) {
|
||||||
|
resolvedId = fullId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
handleClose();
|
handleClose();
|
||||||
onScrollToTask?.(taskId);
|
onScrollToTask?.(resolvedId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangesSectionOpenChange = useCallback((isOpen: boolean): void => {
|
const handleChangesSectionOpenChange = useCallback((isOpen: boolean): void => {
|
||||||
|
|
@ -496,6 +523,7 @@ export const TaskDetailDialog = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
ref={dialogContentRef}
|
||||||
className="sm:min-w-[500px] sm:max-w-4xl"
|
className="sm:min-w-[500px] sm:max-w-4xl"
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
if (lightboxOpenRef.current) e.preventDefault();
|
if (lightboxOpenRef.current) e.preventDefault();
|
||||||
|
|
@ -824,7 +852,25 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : currentTask.description ? (
|
) : currentTask.description ? (
|
||||||
<div className="group relative">
|
<div
|
||||||
|
className="group relative"
|
||||||
|
onClickCapture={
|
||||||
|
onScrollToTask
|
||||||
|
? (e) => {
|
||||||
|
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||||
|
'a[href^="task://"]'
|
||||||
|
);
|
||||||
|
if (link) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
const parsed = href ? parseTaskLinkHref(href) : null;
|
||||||
|
if (parsed?.taskId) handleDependencyClick(parsed.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<ExpandableContent collapsedHeight={200}>
|
<ExpandableContent collapsedHeight={200}>
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={linkifyTaskIdsInMarkdown(
|
content={linkifyTaskIdsInMarkdown(
|
||||||
|
|
@ -1210,6 +1256,7 @@ export const TaskDetailDialog = ({
|
||||||
}
|
}
|
||||||
containerClassName="-mx-6"
|
containerClassName="-mx-6"
|
||||||
unreadCommentIds={unreadSnapshotRef.current}
|
unreadCommentIds={unreadSnapshotRef.current}
|
||||||
|
registerCommentForViewport={registerComment}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,23 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
|
||||||
|
|
||||||
import type { TaskComment } from '@shared/types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks task comments as read when the component is mounted and
|
* Provides a stable ref callback for the comments container.
|
||||||
* whenever the comments list changes while mounted.
|
|
||||||
*
|
*
|
||||||
* Previously used IntersectionObserver, but since the component
|
* Previously this hook auto-marked all comments as read on mount via
|
||||||
* is only rendered inside a CollapsibleTeamSection (conditional
|
* a useEffect. That behavior has been replaced by viewport-based
|
||||||
* mount/unmount controls visibility), a simple effect is both
|
* tracking (useViewportCommentRead) which only marks comments read
|
||||||
* simpler and more reliable — especially inside Dialog portals
|
* when they are scrolled into view inside the dialog.
|
||||||
* where IntersectionObserver can miss the initial intersection.
|
|
||||||
*
|
*
|
||||||
* Returns a ref callback for the comments container (kept for
|
* This hook is kept for API compatibility with TaskCommentsSection
|
||||||
* API compatibility with TaskCommentsSection).
|
* (the ref callback is still attached to the container element).
|
||||||
*/
|
*/
|
||||||
export function useMarkCommentsRead(
|
export function useMarkCommentsRead(
|
||||||
teamName: string,
|
_teamName: string,
|
||||||
taskId: string,
|
_taskId: string,
|
||||||
comments: TaskComment[]
|
_comments: unknown[]
|
||||||
): (node: HTMLElement | null) => void {
|
): (node: HTMLElement | null) => void {
|
||||||
const nodeRef = useRef<HTMLElement | null>(null);
|
const nodeRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
// Mark as read on mount and whenever comments change
|
|
||||||
useEffect(() => {
|
|
||||||
if (comments.length === 0) return;
|
|
||||||
const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime()));
|
|
||||||
if (latest > 0) markAsRead(teamName, taskId, latest);
|
|
||||||
}, [teamName, taskId, comments]);
|
|
||||||
|
|
||||||
// Stable ref callback (no dependencies — just stores the node)
|
// Stable ref callback (no dependencies — just stores the node)
|
||||||
const refCallback = useRef((node: HTMLElement | null) => {
|
const refCallback = useRef((node: HTMLElement | null) => {
|
||||||
nodeRef.current = node;
|
nodeRef.current = node;
|
||||||
|
|
|
||||||
90
src/renderer/hooks/useViewportCommentRead.ts
Normal file
90
src/renderer/hooks/useViewportCommentRead.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
|
|
||||||
|
import { useViewportObserver } from './useViewportObserver';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
|
interface UseViewportCommentReadOptions {
|
||||||
|
teamName: string;
|
||||||
|
taskId: string;
|
||||||
|
/**
|
||||||
|
* Scrollable ancestor element (e.g. DialogContent) used as IO root.
|
||||||
|
* Required for portalled Dialogs where the default viewport root
|
||||||
|
* would not detect intersections correctly.
|
||||||
|
*/
|
||||||
|
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks task comments as read based on viewport visibility.
|
||||||
|
*
|
||||||
|
* Instead of marking all comments read on mount, this hook uses
|
||||||
|
* IntersectionObserver (via useViewportObserver) to detect which
|
||||||
|
* comment elements are visible in the scroll container and updates
|
||||||
|
* the per-task read timestamp to the newest visible comment.
|
||||||
|
*
|
||||||
|
* Each comment element should be registered via the returned
|
||||||
|
* `registerComment(commentTimestampMs)` ref callback.
|
||||||
|
*
|
||||||
|
* Compatible with the existing per-task timestamp storage format
|
||||||
|
* in commentReadStorage — no storage schema changes needed.
|
||||||
|
*/
|
||||||
|
export function useViewportCommentRead({
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
scrollContainerRef,
|
||||||
|
}: UseViewportCommentReadOptions): {
|
||||||
|
/** Ref callback factory. Call with the comment's createdAt timestamp (ms). */
|
||||||
|
registerComment: (timestampMs: number) => (el: HTMLElement | null) => void;
|
||||||
|
/**
|
||||||
|
* Flush the highest observed timestamp now. Call on dialog close
|
||||||
|
* as a safety fallback (e.g. if IO did not fire for portal reasons).
|
||||||
|
*/
|
||||||
|
flush: () => void;
|
||||||
|
} {
|
||||||
|
const highestSeenRef = useRef(0);
|
||||||
|
const teamNameRef = useRef(teamName);
|
||||||
|
const taskIdRef = useRef(taskId);
|
||||||
|
teamNameRef.current = teamName;
|
||||||
|
taskIdRef.current = taskId;
|
||||||
|
|
||||||
|
// Reset tracked state when team/task changes
|
||||||
|
useEffect(() => {
|
||||||
|
highestSeenRef.current = 0;
|
||||||
|
}, [teamName, taskId]);
|
||||||
|
|
||||||
|
const handleVisibleChange = useCallback((visibleValues: string[]) => {
|
||||||
|
let maxTs = 0;
|
||||||
|
for (const v of visibleValues) {
|
||||||
|
const ts = Number(v);
|
||||||
|
if (Number.isFinite(ts) && ts > maxTs) {
|
||||||
|
maxTs = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxTs > 0 && maxTs > highestSeenRef.current) {
|
||||||
|
highestSeenRef.current = maxTs;
|
||||||
|
markAsRead(teamNameRef.current, taskIdRef.current, maxTs);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { registerElement } = useViewportObserver({
|
||||||
|
rootRef: scrollContainerRef,
|
||||||
|
threshold: 0.1,
|
||||||
|
onVisibleChange: handleVisibleChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerComment = useCallback(
|
||||||
|
(timestampMs: number) => registerElement(String(timestampMs)),
|
||||||
|
[registerElement]
|
||||||
|
);
|
||||||
|
|
||||||
|
const flush = useCallback(() => {
|
||||||
|
if (highestSeenRef.current > 0) {
|
||||||
|
markAsRead(teamNameRef.current, taskIdRef.current, highestSeenRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { registerComment, flush };
|
||||||
|
}
|
||||||
119
src/renderer/hooks/useViewportObserver.ts
Normal file
119
src/renderer/hooks/useViewportObserver.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
|
/** Data attribute name used to store arbitrary string data on observed elements. */
|
||||||
|
const DATA_ATTR = 'data-viewport-value';
|
||||||
|
|
||||||
|
interface UseViewportObserverOptions {
|
||||||
|
/**
|
||||||
|
* Scrollable ancestor element used as IntersectionObserver root.
|
||||||
|
* Required for elements inside Dialog portals where the default
|
||||||
|
* document viewport root would not detect intersections correctly.
|
||||||
|
*/
|
||||||
|
rootRef?: RefObject<HTMLElement | null>;
|
||||||
|
/** Visibility ratio threshold (0..1). Default: 0.1 (10% visible). */
|
||||||
|
threshold?: number;
|
||||||
|
/**
|
||||||
|
* Called when the set of visible elements changes.
|
||||||
|
* Receives the data-viewport-value strings of all currently intersecting elements.
|
||||||
|
*/
|
||||||
|
onVisibleChange: (visibleValues: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic reusable hook for detecting which elements are visible in a
|
||||||
|
* scrollable container using IntersectionObserver.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Call the hook with a root ref and a callback.
|
||||||
|
* 2. Attach `registerElement(value)` as a ref callback on each element.
|
||||||
|
* `value` is an arbitrary string stored in a data attribute for identification.
|
||||||
|
* 3. The callback fires with the list of currently visible values whenever
|
||||||
|
* the intersection state changes.
|
||||||
|
*
|
||||||
|
* The hook manages a single IntersectionObserver instance and handles
|
||||||
|
* element registration/deregistration automatically.
|
||||||
|
*/
|
||||||
|
export function useViewportObserver({
|
||||||
|
rootRef,
|
||||||
|
threshold = 0.1,
|
||||||
|
onVisibleChange,
|
||||||
|
}: UseViewportObserverOptions): {
|
||||||
|
/** Ref callback factory. Attach the returned ref to an observed element. */
|
||||||
|
registerElement: (value: string) => (el: HTMLElement | null) => void;
|
||||||
|
} {
|
||||||
|
const onVisibleChangeRef = useRef(onVisibleChange);
|
||||||
|
onVisibleChangeRef.current = onVisibleChange;
|
||||||
|
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const visibleValuesRef = useRef<Set<string>>(new Set());
|
||||||
|
const elementsByValue = useRef<Map<string, HTMLElement>>(new Map());
|
||||||
|
|
||||||
|
// Create / recreate observer when root or threshold changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef?.current ?? null;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const value = entry.target.getAttribute(DATA_ATTR);
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (!visibleValuesRef.current.has(value)) {
|
||||||
|
visibleValuesRef.current.add(value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (visibleValuesRef.current.has(value)) {
|
||||||
|
visibleValuesRef.current.delete(value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
onVisibleChangeRef.current(Array.from(visibleValuesRef.current));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root, threshold }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-observe elements that were registered before observer was created
|
||||||
|
// (or after root changed).
|
||||||
|
for (const [value, el] of elementsByValue.current) {
|
||||||
|
el.setAttribute(DATA_ATTR, value);
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
observerRef.current = observer;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
observerRef.current = null;
|
||||||
|
visibleValuesRef.current.clear();
|
||||||
|
};
|
||||||
|
}, [rootRef, threshold]);
|
||||||
|
|
||||||
|
const registerElement = useCallback((value: string) => {
|
||||||
|
return (el: HTMLElement | null) => {
|
||||||
|
// Cleanup previous element for this value
|
||||||
|
const prev = elementsByValue.current.get(value);
|
||||||
|
if (prev) {
|
||||||
|
observerRef.current?.unobserve(prev);
|
||||||
|
elementsByValue.current.delete(value);
|
||||||
|
visibleValuesRef.current.delete(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register new element
|
||||||
|
if (el) {
|
||||||
|
el.setAttribute(DATA_ATTR, value);
|
||||||
|
elementsByValue.current.set(value, el);
|
||||||
|
observerRef.current?.observe(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { registerElement };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue