diff --git a/README.md b/README.md index 2444e51..679c014 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,9 @@ interface CommentContext { resolveComments: () => Promise<{ error: Error | DOMException | null }>; updateCommentVisibility: (visibility: Partial) => void; triggerError: (error: KnownError) => void; + + // Events - subscribe to comment lifecycle changes + events: CommentEvents; } ``` @@ -550,6 +553,131 @@ function CustomCommentContent() { } ``` +#### `events` (via `useComments()`) + +The `events` object provides a subscription-based system for reacting to comment lifecycle changes. Each event method accepts a callback and returns an `unsubscribe` function. + +**Available Events:** + +| Event | Trigger | Callback Signature | +|-------|---------|-------------------| +| `onFocusChange` | Focus moves to a different comment | `(commentId: string \| null, comment: CommentType \| undefined) => void` | +| `onCommentsConfirmed` | Draft comments are successfully saved | `(comments: CommentType[]) => void` | +| `onCommentsResolved` | Comments are successfully resolved | `(comments: CommentType[]) => void` | +| `onCommentDeleted` | A comment is deleted | `(commentId: string) => void` | +| `onCommentRegistered` | A new draft comment is created | `(comment: CommentType) => void` | + +**Basic Usage:** + +```tsx +import { useComments } from '@kendew-agency/react-feedback-layer'; +import { useEffect } from 'react'; + +function CommentTracker() { + const { events } = useComments(); + + useEffect(() => { + const unsubscribe = events.onFocusChange((commentId, comment) => { + console.log('Focus changed to:', commentId); + if (comment) { + highlightElement(comment.position); + } + }); + + return unsubscribe; + }, [events]); + + useEffect(() => { + const unsubscribe = events.onCommentsConfirmed((comments) => { + console.log(`${comments.length} comments saved`); + toast.success('Comments saved successfully!'); + }); + + return unsubscribe; + }, [events]); + + return null; +} +``` + +**Multiple Subscriptions:** + +```tsx +function AnalyticsTracker() { + const { events } = useComments(); + + useEffect(() => { + const unsubs = [ + events.onCommentRegistered((comment) => { + analytics.track('comment_created', { + position: comment.position, + user: comment.user?.name, + }); + }), + events.onCommentsConfirmed((comments) => { + analytics.track('comments_confirmed', { count: comments.length }); + }), + events.onCommentsResolved((comments) => { + analytics.track('comments_resolved', { count: comments.length }); + }), + events.onCommentDeleted((id) => { + analytics.track('comment_deleted', { commentId: id }); + }), + ]; + + return () => unsubs.forEach((unsub) => unsub()); + }, [events]); + + return null; +} +``` + +**Syncing with External State:** + +```tsx +function CommentSidebar() { + const { events, allComments } = useComments(); + const [activeId, setActiveId] = useState(null); + + useEffect(() => { + return events.onFocusChange((commentId) => { + setActiveId(commentId); + // Scroll sidebar to the active comment + if (commentId) { + document.getElementById(`sidebar-${commentId}`)?.scrollIntoView({ + behavior: 'smooth', + }); + } + }); + }, [events]); + + return ( + + ); +} +``` + +**Type Definitions:** + +```tsx +import type { + CommentEvents, + CommentEventMap, + Unsubscribe, +} from '@kendew-agency/react-feedback-layer/types'; +``` + ### Types Import types from the `/types` subpath: @@ -1211,6 +1339,10 @@ MIT © [Kendew Agency](https://github.com/Kendew-Agency) A list if breaking changes that could impact the way you configured the package +### 0.4.0 +- Added `events` to the `useComments()` hook. You can now subscribe to comment lifecycle events such as `onFocusChange`, `onCommentsConfirmed`, `onCommentsResolved`, `onCommentDeleted`, and `onCommentRegistered`. Each event method returns an unsubscribe function for easy cleanup. +- New types exported: `CommentEvents`, `CommentEventMap`, `Unsubscribe`. + ### 0.3.0 - Added `projectId` prop to `CommentContextProvider`. When set, the project ID is attached to every new comment and passed as a second argument to the `onConfirm` callback. Update your `onConfirm` signature from `(comments) => ...` to `(comments, projectId?) => ...`. - `onError` is now a required prop on `CommentContextProvider`. If you previously omitted it, you must provide an error handler. diff --git a/playground/components/event-listener.tsx b/playground/components/event-listener.tsx new file mode 100644 index 0000000..d8abe53 --- /dev/null +++ b/playground/components/event-listener.tsx @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { useComments } from "../../src"; + +export const EventListener = () => { + const { events } = useComments(); + + useEffect(() => { + const unsubscribe = events.onFocusChange((commentId) => { + console.info("Focus shifted to: ", commentId); + }); + + return unsubscribe; // cleanup on unmount + }, [events]); + + return null; +}; diff --git a/playground/features/comments/comment-wrapper.tsx b/playground/features/comments/comment-wrapper.tsx index 9daf439..aee3ca9 100644 --- a/playground/features/comments/comment-wrapper.tsx +++ b/playground/features/comments/comment-wrapper.tsx @@ -11,6 +11,7 @@ import { } from "./_components/comment"; import { CommentToolbar } from "./_components/toolbar"; import { CommentList } from "./_components/comment-list"; +import { EventListener } from "../../components/event-listener"; export const CommentWrapper = ({ children }: { children: ReactNode }) => { return ( @@ -93,6 +94,7 @@ export const CommentWrapper = ({ children }: { children: ReactNode }) => { indicatorVisibility: "active", }} > +
(null); @@ -174,6 +181,20 @@ export const CommentContextProvider = ({ projectId, } satisfies CommentState); + const { events, emit } = useCommentEvents(); + + // Track focus changes and emit event + const prevFocusRef = useRef(null); + useEffect(() => { + if (state.focussedComment !== prevFocusRef.current) { + const comment = state.comments.find( + (c) => c.id === state.focussedComment, + ); + emit("onFocusChange", state.focussedComment, comment); + prevFocusRef.current = state.focussedComment; + } + }, [state.focussedComment, state.comments, emit]); + // Replace the initial comments with the comments from the listen query useEffect(() => { if (!subscription) return; @@ -198,9 +219,22 @@ export const CommentContextProvider = ({ }); }; + // Emit onCommentRegistered after state updates with new draft + const prevCommentsLengthRef = useRef(state.comments.length); + useEffect(() => { + if (state.comments.length > prevCommentsLengthRef.current) { + const lastComment = state.comments[state.comments.length - 1]; + if (lastComment && lastComment.status === "draft") { + emit("onCommentRegistered", lastComment); + } + } + prevCommentsLengthRef.current = state.comments.length; + }, [state.comments, emit]); + // Delete a comment from the context const deleteComment = (id: string) => { dispatch({ type: "DELETE", id }); + emit("onCommentDeleted", id); }; // Update a comment in the context @@ -295,6 +329,10 @@ export const CommentContextProvider = ({ dispatch({ type: "CHANGE_OVERLAYSTATE", to: "idle" }); dispatch({ type: "RESET_DRAFT_COMMENTS" }); + emit( + "onCommentsConfirmed", + draftComments.map((c) => ({ ...c, status: "published" as const })), + ); return { error: null }; }; @@ -320,6 +358,10 @@ export const CommentContextProvider = ({ dispatch({ type: "CHANGE_OVERLAYSTATE", to: "idle" }); dispatch({ type: "RESET_RESOLVING_COMMENTS" }); + emit( + "onCommentsResolved", + resolvingComments.map((c) => ({ ...c, status: "resolved" as const })), + ); return { error: null }; }; @@ -362,6 +404,7 @@ export const CommentContextProvider = ({ updateCommentVisibility, triggerError, + events, }} > {children} diff --git a/src/lib/use-comment-events.ts b/src/lib/use-comment-events.ts new file mode 100644 index 0000000..814b398 --- /dev/null +++ b/src/lib/use-comment-events.ts @@ -0,0 +1,62 @@ +import { useCallback, useRef } from "react"; +import type { + CommentEventMap, + CommentEvents, + Unsubscribe, +} from "../types/event.types"; + +type Listeners = { + [K in keyof CommentEventMap]: Set; +}; + +/** + * Internal hook that creates the event subscription system. + * Returns both the public `events` object (for consumers) and + * an `emit` function (for the provider to trigger events). + */ +export const useCommentEvents = () => { + const listenersRef = useRef({ + onFocusChange: new Set(), + onCommentsConfirmed: new Set(), + onCommentsResolved: new Set(), + onCommentDeleted: new Set(), + onCommentRegistered: new Set(), + }); + + const subscribe = useCallback( + ( + event: K, + callback: CommentEventMap[K], + ): Unsubscribe => { + listenersRef.current[event].add(callback); + return () => { + listenersRef.current[event].delete(callback); + }; + }, + [], + ); + + const emit = useCallback( + ( + event: K, + ...args: Parameters + ) => { + for (const listener of listenersRef.current[event]) { + (listener as (...a: Parameters) => void)(...args); + } + }, + [], + ); + + const events: CommentEvents = { + onFocusChange: (callback) => subscribe("onFocusChange", callback), + onCommentsConfirmed: (callback) => + subscribe("onCommentsConfirmed", callback), + onCommentsResolved: (callback) => subscribe("onCommentsResolved", callback), + onCommentDeleted: (callback) => subscribe("onCommentDeleted", callback), + onCommentRegistered: (callback) => + subscribe("onCommentRegistered", callback), + }; + + return { events, emit }; +}; diff --git a/src/types/comment.types.ts b/src/types/comment.types.ts index fe18050..acaf035 100644 --- a/src/types/comment.types.ts +++ b/src/types/comment.types.ts @@ -1,3 +1,4 @@ +import type { CommentEvents } from "./event.types"; import type { KnownError } from "./error.types"; import type { CommentOverlayState, @@ -120,6 +121,7 @@ export type CommentContext = { }>; updateCommentVisibility: (visibility: Partial) => void; triggerError: (error: KnownError) => void; + events: CommentEvents; }; export type CommentWithStatus = Omit< diff --git a/src/types/event.types.ts b/src/types/event.types.ts new file mode 100644 index 0000000..edb4b0f --- /dev/null +++ b/src/types/event.types.ts @@ -0,0 +1,50 @@ +import type { CommentType } from "./comment.types"; + +/** + * Event map for comment-related events + * Each key is an event name and the value is the callback signature + */ +export type CommentEventMap = { + /** + * Triggered when focus changes to a different comment + * @param commentId - The id of the newly focused comment, or null if unfocused + * @param comment - The focused comment object, or undefined if unfocused + */ + onFocusChange: ( + commentId: string | null, + comment: CommentType | undefined, + ) => void; + /** + * Triggered when comments are confirmed (saved) + * @param comments - The comments that were confirmed + */ + onCommentsConfirmed: (comments: CommentType[]) => void; + /** + * Triggered when comments are resolved + * @param comments - The comments that were resolved + */ + onCommentsResolved: (comments: CommentType[]) => void; + /** + * Triggered when a comment is deleted + * @param commentId - The id of the deleted comment + */ + onCommentDeleted: (commentId: string) => void; + /** + * Triggered when a new comment is registered (draft created) + * @param comment - The newly registered comment + */ + onCommentRegistered: (comment: CommentType) => void; +}; + +/** + * A function to unsubscribe from an event + */ +export type Unsubscribe = () => void; + +/** + * The events object exposed via useComments() + * Each method accepts a callback and returns an unsubscribe function + */ +export type CommentEvents = { + [K in keyof CommentEventMap]: (callback: CommentEventMap[K]) => Unsubscribe; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 2f46832..48df22e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,3 +2,4 @@ export * from "./comment.types"; export * from "./overlay.types"; export * from "./action.types"; export * from "./error.types"; +export * from "./event.types";