diff --git a/packages/provider/src/TiptapCollabProvider.ts b/packages/provider/src/TiptapCollabProvider.ts index a7093516..d8ca7310 100644 --- a/packages/provider/src/TiptapCollabProvider.ts +++ b/packages/provider/src/TiptapCollabProvider.ts @@ -8,16 +8,27 @@ import { import { TiptapCollabProviderWebsocket } from './TiptapCollabProviderWebsocket.js' import type { + DeleteCommentOptions, TCollabComment, TCollabThread, THistoryVersion, } from './types.js' +const defaultDeleteCommentOptions: DeleteCommentOptions = { + deleteContent: false, + deleteThread: false, +} + export type TiptapCollabProviderConfiguration = Required> & Partial & (Required> | Required>| Required>) & - Pick + Pick & { + /** + * Pass `true` if you want to delete a thread when the first comment is deleted. + */ + deleteThreadOnFirstCommentDelete?: boolean, + } export interface AdditionalTiptapCollabProviderConfiguration { /** @@ -107,14 +118,27 @@ export class TiptapCollabProvider extends HocuspocusProvider { return this.configuration.document.getMap(`${this.tiptapCollabConfigurationPrefix}config`).set('autoVersioning', 0) } + /** + * Returns all users in the document as Y.Map objects + * @returns An array of Y.Map objects + */ private getYThreads() { return this.configuration.document.getArray>(`${this.tiptapCollabConfigurationPrefix}threads`) } + /** + * Finds all threads in the document and returns them as JSON objects + * @returns An array of threads as JSON objects + */ getThreads(): TCollabThread[] { return this.getYThreads().toJSON() as TCollabThread[] } + /** + * Find the index of a thread by its id + * @param id The thread id + * @returns The index of the thread or null if not found + */ private getThreadIndex(id: string): number | null { let index = null @@ -131,6 +155,11 @@ export class TiptapCollabProvider extends HocuspocusProvider { return index } + /** + * Gets a single thread by its id + * @param id The thread id + * @returns The thread as a JSON object or null if not found + */ getThread(id: string): TCollabThread | null { const index = this.getThreadIndex(id) @@ -141,6 +170,11 @@ export class TiptapCollabProvider extends HocuspocusProvider { return this.getYThreads().get(index).toJSON() as TCollabThread } + /** + * Gets a single thread by its id as a Y.Map object + * @param id The thread id + * @returns The thread as a Y.Map object or null if not found + */ private getYThread(id: string) { const index = this.getThreadIndex(id) @@ -151,7 +185,12 @@ export class TiptapCollabProvider extends HocuspocusProvider { return this.getYThreads().get(index) } - createThread(data: Omit) { + /** + * Create a new thread + * @param data The thread data + * @returns The created thread + */ + createThread(data: Omit) { let createdThread: TCollabThread = {} as TCollabThread this.document.transact(() => { @@ -159,6 +198,7 @@ export class TiptapCollabProvider extends HocuspocusProvider { thread.set('id', uuidv4()) thread.set('createdAt', (new Date()).toISOString()) thread.set('comments', new Y.Array()) + thread.set('deletedComments', new Y.Array()) this.getYThreads().push([thread]) createdThread = this.updateThread(String(thread.get('id')), data) @@ -167,6 +207,12 @@ export class TiptapCollabProvider extends HocuspocusProvider { return createdThread } + /** + * Update a specific thread + * @param id The thread id + * @param data New data for the thread + * @returns The updated thread or null if the thread is not found + */ updateThread(id: TCollabThread['id'], data: Partial & { resolvedAt: TCollabThread['resolvedAt'] | null }>) { @@ -195,6 +241,11 @@ export class TiptapCollabProvider extends HocuspocusProvider { return updatedThread } + /** + * Delete a specific thread and all its comments + * @param id The thread id + * @returns void + */ deleteThread(id: TCollabThread['id']) { const index = this.getThreadIndex(id) @@ -205,26 +256,52 @@ export class TiptapCollabProvider extends HocuspocusProvider { this.getYThreads().delete(index, 1) } - getThreadComments(threadId: TCollabThread['id']): TCollabComment[] | null { + /** + * Returns comments from a thread, either deleted or not + * @param threadId The thread id + * @param includeDeleted If you want to include deleted comments, defaults to `false` + * @returns The comments or null if the thread is not found + */ + getThreadComments(threadId: TCollabThread['id'], includeDeleted?: boolean): TCollabComment[] | null { const index = this.getThreadIndex(threadId) if (index === null) { return null } - return this.getThread(threadId)?.comments ?? [] + const comments = !includeDeleted ? this.getThread(threadId)?.comments : [...(this.getThread(threadId)?.comments || []), ...(this.getThread(threadId)?.deletedComments || [])].sort((a, b) => { + return a.createdAt.localeCompare(b.createdAt) + }) + + return comments ?? [] } - getThreadComment(threadId: TCollabThread['id'], commentId: TCollabComment['id']): TCollabComment | null { + /** + * Get a single comment from a specific thread + * @param threadId The thread id + * @param commentId The comment id + * @param includeDeleted If you want to include deleted comments in the search + * @returns The comment or null if not found + */ + getThreadComment(threadId: TCollabThread['id'], commentId: TCollabComment['id'], includeDeleted?: boolean): TCollabComment | null { const index = this.getThreadIndex(threadId) if (index === null) { return null } - return this.getThread(threadId)?.comments.find(comment => comment.id === commentId) ?? null + const comments = this.getThreadComments(threadId, includeDeleted) + + return comments?.find(comment => comment.id === commentId) ?? null } + /** + * Adds a comment to a thread + * @param threadId The thread id + * @param data The comment data + * @returns The updated thread or null if the thread is not found + * @example addComment('123', { content: 'Hello world', data: { author: 'Maria Doe' } }) + */ addComment(threadId: TCollabThread['id'], data: Omit) { let updatedThread: TCollabThread = {} as TCollabThread @@ -246,6 +323,14 @@ export class TiptapCollabProvider extends HocuspocusProvider { return updatedThread } + /** + * Update a comment in a thread + * @param threadId The thread id + * @param commentId The comment id + * @param data The new comment data + * @returns The updated thread or null if the thread or comment is not found + * @example updateComment('123', { content: 'The new content', data: { attachments: ['file1.jpg'] }}) + */ updateComment(threadId: TCollabThread['id'], commentId: TCollabComment['id'], data: Partial>) { let updatedThread: TCollabThread = {} as TCollabThread @@ -281,7 +366,16 @@ export class TiptapCollabProvider extends HocuspocusProvider { return updatedThread } - deleteComment(threadId: TCollabThread['id'], commentId: TCollabComment['id']) { + /** + * Deletes a comment from a thread + * @param threadId The thread id + * @param commentId The comment id + * @param options A set of options that control how the comment is deleted + * @returns The updated thread or null if the thread or comment is not found + */ + deleteComment(threadId: TCollabThread['id'], commentId: TCollabComment['id'], options: DeleteCommentOptions) { + const { deleteContent, deleteThread } = { ...defaultDeleteCommentOptions, ...options } + const thread = this.getYThread(threadId) if (thread === null) return null @@ -297,22 +391,39 @@ export class TiptapCollabProvider extends HocuspocusProvider { // if the first comment of a thread is deleted we also // delete the thread itself as the source comment is gone - if (commentIndex === 0) { + if (commentIndex === 0 && (deleteThread || (this.configuration as TiptapCollabProviderConfiguration).deleteThreadOnFirstCommentDelete)) { this.deleteThread(threadId) return } - if (commentIndex > 0) { - thread.get('comments').delete(commentIndex) - } + const comment = thread.get('comments').get(commentIndex) + const newComment = new Y.Map() + + newComment.set('id', comment.get('id')) + newComment.set('createdAt', comment.get('createdAt')) + newComment.set('updatedAt', (new Date()).toISOString()) + newComment.set('deletedAt', (new Date()).toISOString()) + newComment.set('data', comment.get('data')) + newComment.set('content', deleteContent ? null : comment.get('content')) + + thread.get('deletedComments').push([newComment]) + thread.get('comments').delete(commentIndex) return thread.toJSON() as TCollabThread } + /** + * Start watching threads for changes + * @param callback The callback function to be called when a thread changes + */ watchThreads(callback: () => void) { this.getYThreads().observeDeep(callback) } + /** + * Stop watching threads for changes + * @param callback The callback function to be removed + */ unwatchThreads(callback: () => void) { this.getYThreads().unobserveDeep(callback) } diff --git a/packages/provider/src/types.ts b/packages/provider/src/types.ts index 7e635f20..07c44732 100644 --- a/packages/provider/src/types.ts +++ b/packages/provider/src/types.ts @@ -112,13 +112,15 @@ export type TCollabThread = { updatedAt: number; resolvedAt?: string; // (new Date()).toISOString() comments: TCollabComment[]; + deletedComments: TCollabComment[]; data: Data } export type TCollabComment = { id: string; - createdAt: number; - updatedAt: number; + createdAt: string; + updatedAt: string; + deletedAt?: string; data: Data content: any } @@ -183,3 +185,15 @@ export type THistoryDocumentRevertedEvent = { event: 'document.reverted'; version: number; }; + +export type DeleteCommentOptions = { + /** + * If `true`, the thread will also be deleted if the deleted comment was the first comment in the thread. + */ + deleteThread?: boolean + + /** + * If `true`, will remove the content of the deleted comment + */ + deleteContent?: boolean +}