mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-20 15:57:06 +08:00
feat(core): reply actions (#13071)
fix AF-2717, AF-2716 #### PR Dependency Tree * **PR #13071** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Introduced full editing, replying, and deletion capabilities for individual replies within comments, with consistent UI and state management. * Added support for editing drafts for both comments and replies, allowing users to start, commit, or dismiss edits. * Improved editor focus behavior for a more seamless editing experience. * Added permission checks for comment and reply creation, editing, deletion, and resolution, controlling UI elements accordingly. * Refactored reply rendering into dedicated components with enhanced permission-aware interactions. * **Bug Fixes** * Enhanced comment normalization to handle cases where content may be missing, preventing potential errors. * **Style** * Updated comment and reply UI styles for clearer editing and action states, including new hover and visibility behaviors for reply actions. * **Chores** * Removed unnecessary debugging statements from reply configuration. <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #13071** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -15,6 +15,10 @@ query getDocRolePermissions($workspaceId: String!, $docId: String!) {
|
|||||||
Doc_Update
|
Doc_Update
|
||||||
Doc_Users_Manage
|
Doc_Users_Manage
|
||||||
Doc_Users_Read
|
Doc_Users_Read
|
||||||
|
Doc_Comments_Create
|
||||||
|
Doc_Comments_Delete
|
||||||
|
Doc_Comments_Read
|
||||||
|
Doc_Comments_Resolve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1323,6 +1323,10 @@ export const getDocRolePermissionsQuery = {
|
|||||||
Doc_Update
|
Doc_Update
|
||||||
Doc_Users_Manage
|
Doc_Users_Manage
|
||||||
Doc_Users_Read
|
Doc_Users_Read
|
||||||
|
Doc_Comments_Create
|
||||||
|
Doc_Comments_Delete
|
||||||
|
Doc_Comments_Read
|
||||||
|
Doc_Comments_Resolve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4427,6 +4427,10 @@ export type GetDocRolePermissionsQuery = {
|
|||||||
Doc_Update: boolean;
|
Doc_Update: boolean;
|
||||||
Doc_Users_Manage: boolean;
|
Doc_Users_Manage: boolean;
|
||||||
Doc_Users_Read: boolean;
|
Doc_Users_Read: boolean;
|
||||||
|
Doc_Comments_Create: boolean;
|
||||||
|
Doc_Comments_Delete: boolean;
|
||||||
|
Doc_Comments_Read: boolean;
|
||||||
|
Doc_Comments_Resolve: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
|
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
|
||||||
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
|
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
|
||||||
import { focusTextModel, type RichText } from '@blocksuite/affine/rich-text';
|
import { type RichText, selectTextModel } from '@blocksuite/affine/rich-text';
|
||||||
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
|
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
|
||||||
import { type DocSnapshot, Store } from '@blocksuite/affine/store';
|
import { type DocSnapshot, Store } from '@blocksuite/affine/store';
|
||||||
import { ArrowUpBigIcon } from '@blocksuite/icons/rc';
|
import { ArrowUpBigIcon } from '@blocksuite/icons/rc';
|
||||||
|
import type { TextSelection } from '@blocksuite/std';
|
||||||
import { useFramework, useService } from '@toeverything/infra';
|
import { useFramework, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
||||||
import { getCommentEditorViewManager } from './specs';
|
import { getCommentEditorViewManager } from './specs';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ interface CommentEditorProps {
|
|||||||
|
|
||||||
export interface CommentEditorRef {
|
export interface CommentEditorRef {
|
||||||
getSnapshot: () => DocSnapshot | null | undefined;
|
getSnapshot: () => DocSnapshot | null | undefined;
|
||||||
|
focus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: get rid of circular data changes
|
// todo: get rid of circular data changes
|
||||||
@@ -95,6 +98,32 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
|
|
||||||
const [empty, setEmpty] = useState(true);
|
const [empty, setEmpty] = useState(true);
|
||||||
|
|
||||||
|
const focusEditor = useAsyncCallback(async () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
const selectionService = editorRef.current.std.selection;
|
||||||
|
const selection = selectionService.value.at(-1) as TextSelection;
|
||||||
|
editorRef.current.std.event.active = true;
|
||||||
|
await editorRef.current.host?.updateComplete;
|
||||||
|
if (selection) {
|
||||||
|
selectTextModel(
|
||||||
|
editorRef.current.std,
|
||||||
|
selection.blockId,
|
||||||
|
selection.from.index,
|
||||||
|
selection.from.length
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const richTexts = Array.from(
|
||||||
|
editorRef.current?.querySelectorAll('rich-text') ?? []
|
||||||
|
) as unknown as RichText[];
|
||||||
|
|
||||||
|
const lastRichText = richTexts.at(-1);
|
||||||
|
if (lastRichText) {
|
||||||
|
lastRichText.inlineEditor?.focusEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editorRef]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -104,19 +133,11 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
}
|
}
|
||||||
return snapshotHelper.getSnapshot(doc);
|
return snapshotHelper.getSnapshot(doc);
|
||||||
},
|
},
|
||||||
|
focus: focusEditor,
|
||||||
}),
|
}),
|
||||||
[doc, snapshotHelper]
|
[doc, focusEditor, snapshotHelper]
|
||||||
);
|
);
|
||||||
|
|
||||||
const focusEditor = useCallback(() => {
|
|
||||||
if (editorRef.current) {
|
|
||||||
const lastChild = editorRef.current.std.store.root?.lastChild();
|
|
||||||
if (lastChild) {
|
|
||||||
focusTextModel(editorRef.current.std, lastChild.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [editorRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancel = false;
|
let cancel = false;
|
||||||
if (autoFocus && editorRef.current && doc) {
|
if (autoFocus && editorRef.current && doc) {
|
||||||
@@ -195,7 +216,9 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
data-readonly={!!readonly}
|
data-readonly={!!readonly}
|
||||||
className={clsx(styles.container, 'comment-editor-viewport')}
|
className={clsx(styles.container, 'comment-editor-viewport')}
|
||||||
>
|
>
|
||||||
{doc && <LitDocEditor ref={editorRef} specs={specs} doc={doc} />}
|
{doc && (
|
||||||
|
<LitDocEditor key={doc.id} ref={editorRef} specs={specs} doc={doc} />
|
||||||
|
)}
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export const createCommentLinkedWidgetConfig = (
|
|||||||
|
|
||||||
const items = computed<LinkedMenuItem[]>(() => {
|
const items = computed<LinkedMenuItem[]>(() => {
|
||||||
const members = memberSearchService.result$.signal.value;
|
const members = memberSearchService.result$.signal.value;
|
||||||
console.log('members', members);
|
|
||||||
|
|
||||||
if (query.length === 0) {
|
if (query.length === 0) {
|
||||||
return members
|
return members
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import {
|
|||||||
notify,
|
notify,
|
||||||
useConfirmModal,
|
useConfirmModal,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
|
import { useGuard } from '@affine/core/components/guard';
|
||||||
import { ServerService } from '@affine/core/modules/cloud';
|
import { ServerService } from '@affine/core/modules/cloud';
|
||||||
import { AuthService } from '@affine/core/modules/cloud/services/auth';
|
import { AuthService } from '@affine/core/modules/cloud/services/auth';
|
||||||
import { type DocCommentEntity } from '@affine/core/modules/comment/entities/doc-comment';
|
import { type DocCommentEntity } from '@affine/core/modules/comment/entities/doc-comment';
|
||||||
import { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service';
|
import { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service';
|
||||||
import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager';
|
import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager';
|
||||||
import type { DocComment } from '@affine/core/modules/comment/types';
|
import type {
|
||||||
|
DocComment,
|
||||||
|
DocCommentReply,
|
||||||
|
} from '@affine/core/modules/comment/types';
|
||||||
import { DocService } from '@affine/core/modules/doc';
|
import { DocService } from '@affine/core/modules/doc';
|
||||||
import { toDocSearchParams } from '@affine/core/modules/navigation';
|
import { toDocSearchParams } from '@affine/core/modules/navigation';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
@@ -29,7 +33,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
|
|
||||||
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
||||||
import { CommentEditor } from '../comment-editor';
|
import { CommentEditor, type CommentEditorRef } from '../comment-editor';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
interface CommentFilterState {
|
interface CommentFilterState {
|
||||||
@@ -132,6 +136,11 @@ const CommentItem = ({
|
|||||||
const session = useService(AuthService).session;
|
const session = useService(AuthService).session;
|
||||||
const account = useLiveData(session.account$);
|
const account = useLiveData(session.account$);
|
||||||
|
|
||||||
|
const docId = entity.props.docId;
|
||||||
|
const canCreateComment = useGuard('Doc_Comments_Create', docId);
|
||||||
|
const canDeleteComment = useGuard('Doc_Comments_Delete', docId);
|
||||||
|
const canResolveComment = useGuard('Doc_Comments_Resolve', docId);
|
||||||
|
|
||||||
const pendingReply = useLiveData(entity.pendingReply$);
|
const pendingReply = useLiveData(entity.pendingReply$);
|
||||||
// Check if the pending reply belongs to this comment
|
// Check if the pending reply belongs to this comment
|
||||||
const isReplyingToThisComment = pendingReply?.commentId === comment.id;
|
const isReplyingToThisComment = pendingReply?.commentId === comment.id;
|
||||||
@@ -141,6 +150,13 @@ const CommentItem = ({
|
|||||||
// Loading state for any async operation
|
// Loading state for any async operation
|
||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
|
|
||||||
|
const editingDraft = useLiveData(entity.editingDraft$);
|
||||||
|
const isEditing =
|
||||||
|
editingDraft?.type === 'comment' && editingDraft.id === comment.id;
|
||||||
|
const editingDoc = isEditing ? editingDraft.doc : undefined;
|
||||||
|
|
||||||
|
const [replyEditor, setReplyEditor] = useState<CommentEditorRef | null>(null);
|
||||||
|
|
||||||
const handleDelete = useAsyncCallback(
|
const handleDelete = useAsyncCallback(
|
||||||
async (e: React.MouseEvent) => {
|
async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -178,22 +194,26 @@ const CommentItem = ({
|
|||||||
[entity, comment.id, comment.resolved]
|
[entity, comment.id, comment.resolved]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReply = useAsyncCallback(
|
const handleReply = useAsyncCallback(async () => {
|
||||||
async (e: React.MouseEvent) => {
|
if (comment.resolved) return;
|
||||||
e.stopPropagation();
|
await entity.addReply(comment.id);
|
||||||
if (!comment.resolved) {
|
entity.highlightComment(comment.id);
|
||||||
await entity.addReply(comment.id);
|
if (replyEditor) {
|
||||||
entity.highlightComment(comment.id);
|
// todo: it seems we need to wait for 1000ms
|
||||||
}
|
// to ensure the menu closing animation is complete
|
||||||
},
|
setTimeout(() => {
|
||||||
[entity, comment.id, comment.resolved]
|
replyEditor.focus();
|
||||||
);
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, [entity, comment.id, comment.resolved, replyEditor]);
|
||||||
|
|
||||||
const handleCopyLink = useAsyncCallback(
|
const handleCopyLink = useAsyncCallback(
|
||||||
async (e: React.MouseEvent) => {
|
async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Create a URL with the comment ID
|
// Create a URL with the comment ID
|
||||||
|
|
||||||
|
if (!comment.content) return;
|
||||||
|
|
||||||
const search = toDocSearchParams({
|
const search = toDocSearchParams({
|
||||||
mode: comment.content.mode,
|
mode: comment.content.mode,
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
@@ -209,7 +229,7 @@ const CommentItem = ({
|
|||||||
notify.success({ title: t['Copied link to clipboard']() });
|
notify.success({ title: t['Copied link to clipboard']() });
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
comment.content.mode,
|
comment.content,
|
||||||
comment.id,
|
comment.id,
|
||||||
entity.props.docId,
|
entity.props.docId,
|
||||||
serverService.server.baseUrl,
|
serverService.server.baseUrl,
|
||||||
@@ -239,7 +259,7 @@ const CommentItem = ({
|
|||||||
workbench.workbench.openDoc(
|
workbench.workbench.openDoc(
|
||||||
{
|
{
|
||||||
docId: entity.props.docId,
|
docId: entity.props.docId,
|
||||||
mode: comment.content.mode,
|
mode: comment.content?.mode,
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
refreshKey: 'comment-' + Date.now(),
|
refreshKey: 'comment-' + Date.now(),
|
||||||
},
|
},
|
||||||
@@ -248,7 +268,7 @@ const CommentItem = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
entity.highlightComment(comment.id);
|
entity.highlightComment(comment.id);
|
||||||
}, [comment.id, comment.content.mode, entity, workbench.workbench]);
|
}, [comment.id, comment.content?.mode, entity, workbench.workbench]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = entity.commentHighlighted$
|
const subscription = entity.commentHighlighted$
|
||||||
@@ -293,73 +313,40 @@ const CommentItem = ({
|
|||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
// When the comment item is rendered the first time, the replies will be collapsed by default
|
// Replies handled by ReplyList component; no local collapsed logic here.
|
||||||
// The replies will be collapsed when replies length > 4, that is, the comment, first reply and the last 2 replies
|
|
||||||
// will be shown
|
const handleStartEdit = useAsyncCallback(
|
||||||
// When new reply is added either by clicking the reply button or synced remotely, we will NOT collapse the replies
|
async (e: React.MouseEvent) => {
|
||||||
const [collapsed, setCollapsed] = useState(
|
e.stopPropagation();
|
||||||
(comment.replies?.length ?? 0) > 4
|
if (comment.resolved || !comment.content) return;
|
||||||
|
await entity.startEdit(comment.id, 'comment', comment.content.snapshot);
|
||||||
|
},
|
||||||
|
[entity, comment.id, comment.content, comment.resolved]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderedReplies = useMemo(() => {
|
const handleCommitEdit = useAsyncCallback(async () => {
|
||||||
// Sort replies ascending by createdAt
|
setIsMutating(true);
|
||||||
const sortedReplies =
|
try {
|
||||||
comment.replies?.toSorted((a, b) => a.createdAt - b.createdAt) ?? [];
|
await entity.commitEditing();
|
||||||
if (sortedReplies.length === 0) return null;
|
} finally {
|
||||||
|
setIsMutating(false);
|
||||||
// If not collapsed or replies are fewer than threshold, render all
|
|
||||||
if (!collapsed || sortedReplies.length <= 4) {
|
|
||||||
return sortedReplies.map(reply => (
|
|
||||||
<ReadonlyCommentRenderer
|
|
||||||
key={reply.id}
|
|
||||||
avatarUrl={reply.user.avatarUrl}
|
|
||||||
name={reply.user.name}
|
|
||||||
time={reply.createdAt}
|
|
||||||
snapshot={reply.content.snapshot}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
}, [entity]);
|
||||||
|
|
||||||
// Collapsed state: first reply + collapsed indicator + last two replies
|
const handleCancelEdit = useCallback(() => {
|
||||||
const firstReply = sortedReplies[0];
|
entity.dismissDraftEditing();
|
||||||
const tailReplies = sortedReplies.slice(-2);
|
}, [entity]);
|
||||||
|
|
||||||
return (
|
const isMyComment = account && account.id === comment.user.id;
|
||||||
<>
|
const canReply = canCreateComment;
|
||||||
{firstReply && (
|
const canEdit = isMyComment && canCreateComment;
|
||||||
<ReadonlyCommentRenderer
|
const canDelete =
|
||||||
key={firstReply.id}
|
(isMyComment && canCreateComment) || (!isMyComment && canDeleteComment);
|
||||||
avatarUrl={firstReply.user.avatarUrl}
|
|
||||||
name={firstReply.user.name}
|
// invalid comment, should not happen
|
||||||
time={firstReply.createdAt}
|
if (!comment.content) {
|
||||||
snapshot={firstReply.content.snapshot}
|
return null;
|
||||||
/>
|
}
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={styles.collapsedReplies}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setCollapsed(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.collapsedRepliesTitle}>
|
|
||||||
{t['com.affine.comment.reply.show-more']({
|
|
||||||
count: (sortedReplies.length - 4).toString(),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{tailReplies.map(reply => (
|
|
||||||
<ReadonlyCommentRenderer
|
|
||||||
key={reply.id}
|
|
||||||
avatarUrl={reply.user.avatarUrl}
|
|
||||||
name={reply.user.name}
|
|
||||||
time={reply.createdAt}
|
|
||||||
snapshot={reply.content.snapshot}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, [collapsed, comment.replies, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -370,13 +357,19 @@ const CommentItem = ({
|
|||||||
className={styles.commentItem}
|
className={styles.commentItem}
|
||||||
ref={commentRef}
|
ref={commentRef}
|
||||||
>
|
>
|
||||||
<div className={styles.commentActions} data-menu-open={menuOpen}>
|
<div
|
||||||
<IconButton
|
className={styles.commentActions}
|
||||||
variant="solid"
|
data-menu-open={menuOpen}
|
||||||
onClick={handleResolve}
|
data-editing={isEditing}
|
||||||
icon={<DoneIcon />}
|
>
|
||||||
disabled={isMutating}
|
{!comment.resolved && canResolveComment && (
|
||||||
/>
|
<IconButton
|
||||||
|
variant="solid"
|
||||||
|
onClick={handleResolve}
|
||||||
|
icon={<DoneIcon />}
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Menu
|
<Menu
|
||||||
rootOptions={{
|
rootOptions={{
|
||||||
open: menuOpen,
|
open: menuOpen,
|
||||||
@@ -386,22 +379,34 @@ const CommentItem = ({
|
|||||||
}}
|
}}
|
||||||
items={
|
items={
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
{canReply ? (
|
||||||
onClick={handleReply}
|
<MenuItem
|
||||||
disabled={isMutating || comment.resolved}
|
onClick={handleReply}
|
||||||
>
|
disabled={isMutating || comment.resolved}
|
||||||
{t['com.affine.comment.reply']()}
|
>
|
||||||
</MenuItem>
|
{t['com.affine.comment.reply']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
<MenuItem onClick={handleCopyLink} disabled={isMutating}>
|
<MenuItem onClick={handleCopyLink} disabled={isMutating}>
|
||||||
{t['com.affine.comment.copy-link']()}
|
{t['com.affine.comment.copy-link']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
{canEdit ? (
|
||||||
onClick={handleDelete}
|
<MenuItem
|
||||||
disabled={isMutating}
|
onClick={handleStartEdit}
|
||||||
style={{ color: 'var(--affine-error-color)' }}
|
disabled={isMutating || comment.resolved}
|
||||||
>
|
>
|
||||||
{t['Delete']()}
|
{t['Edit']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{canDelete ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isMutating}
|
||||||
|
style={{ color: 'var(--affine-error-color)' }}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -412,31 +417,57 @@ const CommentItem = ({
|
|||||||
/>
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.previewContainer}>{comment.content.preview}</div>
|
<div className={styles.previewContainer}>{comment.content?.preview}</div>
|
||||||
|
|
||||||
<div className={styles.repliesContainer}>
|
<div className={styles.repliesContainer}>
|
||||||
<ReadonlyCommentRenderer
|
{isEditing && editingDoc ? (
|
||||||
avatarUrl={comment.user.avatarUrl}
|
<div className={styles.commentInputContainer}>
|
||||||
name={comment.user.name}
|
<div className={styles.userContainer}>
|
||||||
time={comment.createdAt}
|
<Avatar url={account?.avatar} size={24} />
|
||||||
snapshot={comment.content.snapshot}
|
</div>
|
||||||
/>
|
<CommentEditor
|
||||||
{renderedReplies}
|
doc={editingDoc}
|
||||||
|
autoFocus
|
||||||
|
onCommit={isMutating ? undefined : handleCommitEdit}
|
||||||
|
onCancel={isMutating ? undefined : handleCancelEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReadonlyCommentRenderer
|
||||||
|
avatarUrl={comment.user.avatarUrl}
|
||||||
|
name={comment.user.name}
|
||||||
|
time={comment.createdAt}
|
||||||
|
snapshot={comment.content.snapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{comment.replies && comment.replies.length > 0 && (
|
||||||
|
<ReplyList
|
||||||
|
replies={comment.replies}
|
||||||
|
parentComment={comment}
|
||||||
|
entity={entity}
|
||||||
|
replyEditor={replyEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{highlighting &&
|
{!editingDraft &&
|
||||||
|
highlighting &&
|
||||||
isReplyingToThisComment &&
|
isReplyingToThisComment &&
|
||||||
pendingReply &&
|
pendingReply &&
|
||||||
account &&
|
account &&
|
||||||
!comment.resolved && (
|
!comment.resolved &&
|
||||||
|
canCreateComment && (
|
||||||
<div className={styles.commentInputContainer}>
|
<div className={styles.commentInputContainer}>
|
||||||
<div className={styles.userContainer}>
|
<div className={styles.userContainer}>
|
||||||
<Avatar url={account.avatar} size={24} />
|
<Avatar url={account.avatar} size={24} />
|
||||||
</div>
|
</div>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
|
ref={setReplyEditor}
|
||||||
autoFocus
|
autoFocus
|
||||||
doc={pendingReply.doc}
|
doc={pendingReply.doc}
|
||||||
onCommit={isMutating ? undefined : handleCommitReply}
|
onCommit={
|
||||||
|
isMutating || !canCreateComment ? undefined : handleCommitReply
|
||||||
|
}
|
||||||
onCancel={isMutating ? undefined : handleCancelReply}
|
onCancel={isMutating ? undefined : handleCancelReply}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,7 +539,7 @@ const CommentList = ({ entity }: { entity: DocCommentEntity }) => {
|
|||||||
if (filterState.onlyCurrentMode) {
|
if (filterState.onlyCurrentMode) {
|
||||||
filteredComments = filteredComments.filter(comment => {
|
filteredComments = filteredComments.filter(comment => {
|
||||||
return (
|
return (
|
||||||
!comment.content.mode || !docMode || comment.content.mode === docMode
|
!comment.content?.mode || !docMode || comment.content.mode === docMode
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -568,6 +599,9 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
|||||||
const pendingPreview = newPendingComment?.preview;
|
const pendingPreview = newPendingComment?.preview;
|
||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
|
|
||||||
|
const docId = entity.props.docId;
|
||||||
|
const canCreateComment = useGuard('Doc_Comments_Create', docId);
|
||||||
|
|
||||||
const handleCommit = useAsyncCallback(async () => {
|
const handleCommit = useAsyncCallback(async () => {
|
||||||
if (!newPendingComment?.id) return;
|
if (!newPendingComment?.id) return;
|
||||||
setIsMutating(true);
|
setIsMutating(true);
|
||||||
@@ -587,7 +621,7 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
|||||||
const session = useService(AuthService).session;
|
const session = useService(AuthService).session;
|
||||||
const account = useLiveData(session.account$);
|
const account = useLiveData(session.account$);
|
||||||
|
|
||||||
if (!newPendingComment || !account) {
|
if (!newPendingComment || !account || !canCreateComment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,7 +637,7 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
|||||||
<CommentEditor
|
<CommentEditor
|
||||||
autoFocus
|
autoFocus
|
||||||
doc={newPendingComment.doc}
|
doc={newPendingComment.doc}
|
||||||
onCommit={isMutating ? undefined : handleCommit}
|
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
|
||||||
onCancel={isMutating ? undefined : handleCancel}
|
onCancel={isMutating ? undefined : handleCancel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -611,6 +645,253 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ReplyItemProps {
|
||||||
|
reply: DocCommentReply;
|
||||||
|
parentComment: DocComment;
|
||||||
|
entity: DocCommentEntity;
|
||||||
|
replyEditor: CommentEditorRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReplyItem = ({
|
||||||
|
reply,
|
||||||
|
parentComment,
|
||||||
|
entity,
|
||||||
|
replyEditor,
|
||||||
|
}: ReplyItemProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const session = useService(AuthService).session;
|
||||||
|
const account = useLiveData(session.account$);
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
|
const editingDraft = useLiveData(entity.editingDraft$);
|
||||||
|
const isEditingThisReply =
|
||||||
|
editingDraft?.type === 'reply' && editingDraft.id === reply.id;
|
||||||
|
const editingDoc = isEditingThisReply ? editingDraft.doc : undefined;
|
||||||
|
|
||||||
|
const docId = entity.props.docId;
|
||||||
|
const canCreateComment = useGuard('Doc_Comments_Create', docId);
|
||||||
|
const canDeleteComment = useGuard('Doc_Comments_Delete', docId);
|
||||||
|
|
||||||
|
const handleStartEdit = useAsyncCallback(async () => {
|
||||||
|
if (parentComment.resolved || !reply.content) return;
|
||||||
|
await entity.startEdit(reply.id, 'reply', reply.content.snapshot);
|
||||||
|
}, [entity, parentComment.resolved, reply.id, reply.content]);
|
||||||
|
|
||||||
|
const handleCommitEdit = useAsyncCallback(async () => {
|
||||||
|
setIsMutating(true);
|
||||||
|
try {
|
||||||
|
await entity.commitEditing();
|
||||||
|
} finally {
|
||||||
|
setIsMutating(false);
|
||||||
|
}
|
||||||
|
}, [entity]);
|
||||||
|
|
||||||
|
const handleCancelEdit = useCallback(
|
||||||
|
() => entity.dismissDraftEditing(),
|
||||||
|
[entity]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useAsyncCallback(async () => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t['com.affine.comment.reply.delete.confirm.title'](),
|
||||||
|
description: t['com.affine.comment.reply.delete.confirm.description'](),
|
||||||
|
confirmText: t['Delete'](),
|
||||||
|
cancelText: t['Cancel'](),
|
||||||
|
confirmButtonOptions: {
|
||||||
|
variant: 'error',
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
setIsMutating(true);
|
||||||
|
try {
|
||||||
|
await entity.deleteReply(reply.id);
|
||||||
|
} finally {
|
||||||
|
setIsMutating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openConfirmModal, t, entity, reply.id]);
|
||||||
|
|
||||||
|
const handleReply = useAsyncCallback(async () => {
|
||||||
|
if (parentComment.resolved) return;
|
||||||
|
await entity.addReply(parentComment.id, reply);
|
||||||
|
entity.highlightComment(parentComment.id);
|
||||||
|
if (replyEditor) {
|
||||||
|
// todo: find out why we need to wait for 100ms
|
||||||
|
setTimeout(() => {
|
||||||
|
replyEditor.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [entity, parentComment.id, parentComment.resolved, reply, replyEditor]);
|
||||||
|
|
||||||
|
const isMyReply = account && account.id === reply.user.id;
|
||||||
|
const canReply = canCreateComment;
|
||||||
|
const canEdit = isMyReply && canCreateComment;
|
||||||
|
const canDelete =
|
||||||
|
(isMyReply && canCreateComment) || (!isMyReply && canDeleteComment);
|
||||||
|
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// invalid reply, should not happen
|
||||||
|
if (!reply.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.replyItem} data-reply-id={reply.id}>
|
||||||
|
<div
|
||||||
|
className={styles.replyActions}
|
||||||
|
data-menu-open={isMenuOpen}
|
||||||
|
data-editing={isEditingThisReply}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
rootOptions={{
|
||||||
|
onOpenChange: setIsMenuOpen,
|
||||||
|
open: isMenuOpen,
|
||||||
|
}}
|
||||||
|
items={
|
||||||
|
<>
|
||||||
|
{canReply ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleReply}
|
||||||
|
disabled={isMutating || parentComment.resolved}
|
||||||
|
>
|
||||||
|
{t['com.affine.comment.reply']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{canEdit ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleStartEdit}
|
||||||
|
disabled={isMutating || parentComment.resolved}
|
||||||
|
>
|
||||||
|
{t['Edit']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{canDelete ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleDelete}
|
||||||
|
style={{ color: 'var(--affine-error-color)' }}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={<MoreHorizontalIcon />}
|
||||||
|
variant="solid"
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingThisReply && editingDoc ? (
|
||||||
|
<div className={styles.commentInputContainer}>
|
||||||
|
<div className={styles.userContainer}>
|
||||||
|
<Avatar url={account?.avatar} size={24} />
|
||||||
|
</div>
|
||||||
|
<CommentEditor
|
||||||
|
doc={editingDoc}
|
||||||
|
autoFocus
|
||||||
|
onCommit={isMutating ? undefined : handleCommitEdit}
|
||||||
|
onCancel={isMutating ? undefined : handleCancelEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReadonlyCommentRenderer
|
||||||
|
avatarUrl={reply.user.avatarUrl}
|
||||||
|
name={reply.user.name}
|
||||||
|
time={reply.createdAt}
|
||||||
|
snapshot={reply.content.snapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReplyListProps {
|
||||||
|
replies: DocCommentReply[];
|
||||||
|
parentComment: DocComment;
|
||||||
|
entity: DocCommentEntity;
|
||||||
|
replyEditor: CommentEditorRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReplyList = ({
|
||||||
|
replies,
|
||||||
|
parentComment,
|
||||||
|
entity,
|
||||||
|
replyEditor,
|
||||||
|
}: ReplyListProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
// When the comment item is rendered the first time, the replies will be collapsed by default
|
||||||
|
// The replies will be collapsed when replies length > 4, that is, the comment, first reply and the last 2 replies
|
||||||
|
// will be shown
|
||||||
|
// When new reply is added either by clicking the reply button or synced remotely, we will NOT collapse the replies
|
||||||
|
const [collapsed, setCollapsed] = useState((replies.length ?? 0) > 4);
|
||||||
|
|
||||||
|
const renderedReplies = useMemo(() => {
|
||||||
|
// Sort replies ascending by createdAt
|
||||||
|
const sortedReplies =
|
||||||
|
replies.toSorted((a, b) => a.createdAt - b.createdAt) ?? [];
|
||||||
|
if (sortedReplies.length === 0) return null;
|
||||||
|
|
||||||
|
// If not collapsed or replies are fewer than threshold, render all
|
||||||
|
if (!collapsed || sortedReplies.length <= 4) {
|
||||||
|
return sortedReplies.map(reply => (
|
||||||
|
<ReplyItem
|
||||||
|
key={reply.id}
|
||||||
|
reply={reply}
|
||||||
|
parentComment={parentComment}
|
||||||
|
entity={entity}
|
||||||
|
replyEditor={replyEditor}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed state: first reply + collapsed indicator + last two replies
|
||||||
|
const firstReply = sortedReplies[0];
|
||||||
|
const tailReplies = sortedReplies.slice(-2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReplyItem
|
||||||
|
key={firstReply.id}
|
||||||
|
reply={firstReply}
|
||||||
|
parentComment={parentComment}
|
||||||
|
entity={entity}
|
||||||
|
replyEditor={replyEditor}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={styles.collapsedReplies}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsed(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.collapsedRepliesTitle}>
|
||||||
|
{t['com.affine.comment.reply.show-more']({
|
||||||
|
count: (sortedReplies.length - 4).toString(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tailReplies.map(reply => (
|
||||||
|
<ReplyItem
|
||||||
|
key={reply.id}
|
||||||
|
reply={reply}
|
||||||
|
parentComment={parentComment}
|
||||||
|
entity={entity}
|
||||||
|
replyEditor={replyEditor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [collapsed, replies, t, entity, parentComment, replyEditor]);
|
||||||
|
|
||||||
|
return <div className={styles.repliesContainer}>{renderedReplies}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
const useCommentEntity = (docId: string | undefined) => {
|
const useCommentEntity = (docId: string | undefined) => {
|
||||||
const docCommentManager = useService(DocCommentManagerService);
|
const docCommentManager = useService(DocCommentManagerService);
|
||||||
const commentPanelService = useService(CommentPanelService);
|
const commentPanelService = useService(CommentPanelService);
|
||||||
@@ -652,12 +933,14 @@ export const CommentSidebar = () => {
|
|||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
entity?.highlightComment(null);
|
entity?.highlightComment(null);
|
||||||
|
entity?.dismissDraftEditing();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleContainerClick = (event: MouseEvent) => {
|
const handleContainerClick = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.closest('[data-comment-id]')) {
|
if (!target.closest('[data-comment-id]')) {
|
||||||
entity?.highlightComment(null);
|
entity?.highlightComment(null);
|
||||||
|
entity?.dismissDraftEditing();
|
||||||
}
|
}
|
||||||
// if creating a new comment, dismiss the comment input
|
// if creating a new comment, dismiss the comment input
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ export const commentActions = style({
|
|||||||
'&[data-menu-open="true"]': {
|
'&[data-menu-open="true"]': {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
'&[data-editing="true"]': {
|
||||||
|
visibility: 'hidden',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ export const readonlyCommentContainer = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '4px',
|
gap: '4px',
|
||||||
paddingLeft: '8px',
|
paddingLeft: '8px',
|
||||||
|
position: 'relative',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userContainer = style({
|
export const userContainer = style({
|
||||||
@@ -210,3 +214,32 @@ export const collapsedRepliesTitle = style({
|
|||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const replyItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const replyActions = style({
|
||||||
|
display: 'flex',
|
||||||
|
opacity: 0,
|
||||||
|
gap: '8px',
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
selectors: {
|
||||||
|
[`${replyItem}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
'&[data-menu-open="true"]': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
'&[data-editing="true"]': {
|
||||||
|
visibility: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
|
|||||||
|
|
||||||
const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
content: comment.content as DocCommentContent,
|
content: comment.content ? (comment.content as DocCommentContent) : undefined,
|
||||||
resolved: comment.resolved,
|
resolved: comment.resolved,
|
||||||
createdAt: new Date(comment.createdAt).getTime(),
|
createdAt: new Date(comment.createdAt).getTime(),
|
||||||
updatedAt: new Date(comment.updatedAt).getTime(),
|
updatedAt: new Date(comment.updatedAt).getTime(),
|
||||||
@@ -60,7 +60,9 @@ const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
|||||||
name: '',
|
name: '',
|
||||||
avatarUrl: '',
|
avatarUrl: '',
|
||||||
},
|
},
|
||||||
mentions: findMentions(comment.content.snapshot.blocks),
|
mentions: comment.content
|
||||||
|
? findMentions(comment.content.snapshot.blocks)
|
||||||
|
: [],
|
||||||
replies: comment.replies?.map(normalizeReply) ?? [],
|
replies: comment.replies?.map(normalizeReply) ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { type CommentChangeAction, DocMode } from '@affine/graphql';
|
import { type CommentChangeAction, DocMode } from '@affine/graphql';
|
||||||
import type { BaseSelection } from '@blocksuite/affine/store';
|
import type {
|
||||||
|
BaseSelection,
|
||||||
|
DocSnapshot,
|
||||||
|
Store,
|
||||||
|
} from '@blocksuite/affine/store';
|
||||||
import {
|
import {
|
||||||
effect,
|
effect,
|
||||||
Entity,
|
Entity,
|
||||||
@@ -63,6 +67,13 @@ export class DocCommentEntity extends Entity<{
|
|||||||
// Only one pending reply at a time
|
// Only one pending reply at a time
|
||||||
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
||||||
|
|
||||||
|
// Draft state for editing existing comment or reply (only one at a time)
|
||||||
|
readonly editingDraft$ = new LiveData<{
|
||||||
|
id: CommentId;
|
||||||
|
type: 'comment' | 'reply';
|
||||||
|
doc: Store;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
private readonly commentAdded$ = new Subject<{
|
private readonly commentAdded$ = new Subject<{
|
||||||
id: CommentId;
|
id: CommentId;
|
||||||
selections: BaseSelection[];
|
selections: BaseSelection[];
|
||||||
@@ -78,13 +89,15 @@ export class DocCommentEntity extends Entity<{
|
|||||||
selections?: BaseSelection[],
|
selections?: BaseSelection[],
|
||||||
preview?: string
|
preview?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// todo: may need to properly bind the doc to the editor
|
// check if there is a pending comment, reuse it
|
||||||
const doc = await this.snapshotHelper.createStore();
|
let pendingComment = this.pendingComment$.value;
|
||||||
|
const doc =
|
||||||
|
pendingComment?.doc ?? (await this.snapshotHelper.createStore());
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error('Failed to create doc');
|
throw new Error('Failed to create doc');
|
||||||
}
|
}
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
const pendingComment: PendingComment = {
|
pendingComment = {
|
||||||
id,
|
id,
|
||||||
doc,
|
doc,
|
||||||
preview,
|
preview,
|
||||||
@@ -96,18 +109,35 @@ export class DocCommentEntity extends Entity<{
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addReply(commentId: string): Promise<string> {
|
/**
|
||||||
const doc = await this.snapshotHelper.createStore();
|
* Add a reply to a comment.
|
||||||
|
* If reply is provided, the content should @mention the user who is replying to.
|
||||||
|
*/
|
||||||
|
async addReply(commentId: string, reply?: DocCommentReply): Promise<string> {
|
||||||
|
// check if there is a pending reply, reuse it
|
||||||
|
let pendingReply = this.pendingReply$.value;
|
||||||
|
const doc = pendingReply?.doc ?? (await this.snapshotHelper.createStore());
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error('Failed to create doc');
|
throw new Error('Failed to create doc');
|
||||||
}
|
}
|
||||||
|
const mention: string | undefined = reply?.user.id;
|
||||||
|
if (mention) {
|
||||||
|
// insert mention at the end of the paragraph
|
||||||
|
const paragraph = doc.getModelsByFlavour('affine:paragraph').at(-1);
|
||||||
|
if (paragraph) {
|
||||||
|
paragraph.text?.insert(' ', paragraph.text.length, {
|
||||||
|
mention: {
|
||||||
|
member: mention,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
const pendingReply: PendingComment = {
|
pendingReply = {
|
||||||
id,
|
id,
|
||||||
doc,
|
doc,
|
||||||
commentId,
|
commentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Replace any existing pending reply (only one at a time)
|
// Replace any existing pending reply (only one at a time)
|
||||||
this.pendingReply$.setValue(pendingReply);
|
this.pendingReply$.setValue(pendingReply);
|
||||||
return id;
|
return id;
|
||||||
@@ -121,6 +151,47 @@ export class DocCommentEntity extends Entity<{
|
|||||||
this.pendingReply$.setValue(null);
|
this.pendingReply$.setValue(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing an existing comment or reply.
|
||||||
|
* This will dismiss any previous editing draft so that only one can exist.
|
||||||
|
*/
|
||||||
|
async startEdit(
|
||||||
|
id: CommentId,
|
||||||
|
type: 'comment' | 'reply',
|
||||||
|
snapshot: DocSnapshot
|
||||||
|
): Promise<void> {
|
||||||
|
const doc = await this.snapshotHelper.createStore(snapshot);
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error('Failed to create doc for editing');
|
||||||
|
}
|
||||||
|
this.editingDraft$.setValue({ id, type, doc });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit current editing draft (if any) */
|
||||||
|
async commitEditing(): Promise<void> {
|
||||||
|
const draft = this.editingDraft$.value;
|
||||||
|
if (!draft) return;
|
||||||
|
|
||||||
|
const snapshot = this.snapshotHelper.getSnapshot(draft.doc);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error('Failed to get snapshot');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.type === 'comment') {
|
||||||
|
await this.updateComment(draft.id, { snapshot });
|
||||||
|
} else {
|
||||||
|
await this.updateReply(draft.id, { snapshot });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editingDraft$.setValue(null);
|
||||||
|
this.revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss current editing draft without saving */
|
||||||
|
dismissDraftEditing(): void {
|
||||||
|
this.editingDraft$.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
get docMode$() {
|
get docMode$() {
|
||||||
return this.framework.get(GlobalContextService).globalContext.docMode.$;
|
return this.framework.get(GlobalContextService).globalContext.docMode.$;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type CommentId = string;
|
|||||||
|
|
||||||
export interface BaseComment {
|
export interface BaseComment {
|
||||||
id: CommentId;
|
id: CommentId;
|
||||||
content: DocCommentContent;
|
content?: DocCommentContent;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
user: PublicUserType;
|
user: PublicUserType;
|
||||||
|
|||||||
Reference in New Issue
Block a user