Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/modules.editor/src/hooks/useCollaborators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useCollaboratorsStore } from '../store/collaboratorsStore';

export const useCollaborators = () => {
const collaborators = useCollaboratorsStore((s) => s.collaborators);
const setCollaborators = useCollaboratorsStore((s) => s.setCollaborators);
const setCollaboratorsIfChanged = useCollaboratorsStore((s) => s.setCollaborators);
const reset = useCollaboratorsStore((s) => s.reset);

return { collaborators, setCollaborators, setCollaboratorsIfChanged, reset };
};
33 changes: 29 additions & 4 deletions packages/modules.editor/src/hooks/useYjsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type onAuthenticationFailedParameters,
} from '@hocuspocus/provider';
import { generateUserColor } from '../utils/userColor';
import { useCollaborators } from './useCollaborators';
import { TCollaborator } from '../types';

type UseYjsStoreArgs = {
hostUrl: string;
Expand Down Expand Up @@ -60,6 +62,9 @@ export function useYjsStore({
return { provider, ydoc };
});

const { awareness } = provider;
const { setCollaboratorsIfChanged, reset } = useCollaborators();

/* ==========================================================
* 2. Readonly state
* ========================================================== */
Expand All @@ -70,9 +75,10 @@ export function useYjsStore({
* ========================================================== */
const { data: currentUser } = useCurrentUser();
const userData = useMemo(() => {
const id = currentUser?.id;
const name = currentUser?.display_name || currentUser?.username || 'Участник';
const idForColor = currentUser?.id?.toString() ?? 'anonymous';
return { name, color: generateUserColor(idForColor) };
return { id, name, color: generateUserColor(idForColor) };
}, [currentUser?.id, currentUser?.display_name, currentUser?.username]);

/* ==========================================================
Expand All @@ -96,8 +102,8 @@ export function useYjsStore({
}, 0);

// Awareness
if (provider.awareness) {
provider.awareness.setLocalStateField('user', userData);
if (awareness) {
awareness.setLocalStateField('user', userData);
}

// Auth events
Expand Down Expand Up @@ -129,7 +135,26 @@ export function useYjsStore({
// ignore
}
};
}, [provider, userData]);
}, [provider, awareness, userData]);

useEffect(() => {
if (!awareness) return;

const handleSyncUsersFromAwareness = () => {
const awarenessUsers: TCollaborator[] = [...awareness.getStates()]
.map((arr) => ({ id: arr[1].user?.id, userName: arr[1].user.name }))
.filter((user) => user.id);
setCollaboratorsIfChanged(awarenessUsers);
};

awareness.on('update', handleSyncUsersFromAwareness);
handleSyncUsersFromAwareness();

return () => {
awareness.off('update', handleSyncUsersFromAwareness);
reset();
};
}, [awareness, setCollaboratorsIfChanged, reset]);

/* ==========================================================
* 6. Editor — extensions в deps: при загрузке currentUser
Expand Down
1 change: 1 addition & 0 deletions packages/modules.editor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { Editor } from './ui/Editor';
export { useInterfaceStore } from './store/interfaceStore';
export { useYjsContext } from './hooks/useYjsContext';
export { useCollaborators } from './hooks/useCollaborators';

export { isUrl, isImageUrl } from './utils/isUrl';
export { normalizeTokens } from './utils/normalizeTokens';
Expand Down
21 changes: 21 additions & 0 deletions packages/modules.editor/src/store/collaboratorsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { create } from 'zustand';
import type { TCollaborator } from '../types';

type TCollaboratorsStore = {
collaborators: TCollaborator[];
setCollaborators: (collaborators: TCollaborator[]) => void;
setCollaboratorsIfChanged: (collaborators: TCollaborator[]) => void;
reset: () => void;
};

export const useCollaboratorsStore = create<TCollaboratorsStore>()((set, get) => ({
collaborators: [],
setCollaborators: (collaborators) => set({ collaborators }),
setCollaboratorsIfChanged: (collaborators) => {
const s = get();
if (JSON.stringify(s.collaborators) !== JSON.stringify(collaborators)) {
set({ collaborators });
}
},
reset: () => set({ collaborators: [] }),
}));
5 changes: 5 additions & 0 deletions packages/modules.editor/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export type { TextFormatTypeT } from './bubbleMenu';
export type { BlockTypeT } from './blockMenu';
export type { ActiveBlockT } from './activeBlock';

export type TCollaborator = {
id: number;
userName: string;
};
13 changes: 10 additions & 3 deletions packages/modules.editor/src/ui/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { YjsProvider } from '../providers/YjsProvider';
import { TiptapEditor } from './components/TiptapEditor';
import { useParams } from '@tanstack/react-router';
Expand All @@ -11,6 +10,14 @@ import {
import { StorageItemT } from 'common.types';
import { LoadingScreen } from 'common.ui';

type TEditorWithData = {
storageItem: StorageItemT;
};

type TEditor = {
storageItem?: StorageItemT;
};

const EditorWithoutData = () => {
const { classroomId, noteId, materialId } = useParams({ strict: false });

Expand Down Expand Up @@ -63,15 +70,15 @@ const EditorWithoutData = () => {
);
};

const EditorWithData = ({ storageItem }: { storageItem: StorageItemT }) => {
const EditorWithData = ({ storageItem }: TEditorWithData) => {
return (
<YjsProvider key={storageItem.ydoc_id} data={storageItem}>
<TiptapEditor />
</YjsProvider>
);
};

export const Editor = ({ storageItem }: { storageItem?: StorageItemT }) => {
export const Editor = ({ storageItem }: TEditor) => {
if (storageItem) {
return <EditorWithData storageItem={storageItem} />;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/pages.notes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
},
"dependencies": {
"@tanstack/react-router": "^1.166.6",
"@xipkg/avatar": "3.3.0",
"@xipkg/utils": "^1.8.0",
"@xipkg/button": "4.1.0",
"@xipkg/icons": "3.0.20",
"@xipkg/form": "4.2.2",
"@xipkg/input": "2.2.14",
"@xipkg/popover": "^2.1.0",
"common.env": "workspace:*",
"common.services": "workspace:*",
"common.ui": "workspace:*",
"modules.editor": "workspace:*"
Expand Down
6 changes: 6 additions & 0 deletions packages/pages.notes/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type TCollaborator = {
id: number;
userName: string;
avatarUrl?: string;
initial?: string;
};
57 changes: 57 additions & 0 deletions packages/pages.notes/src/ui/CollaboratorAvatars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@xipkg/avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@xipkg/popover';
import { TCollaborator } from '../types';

type TCollaboratorAvatars = {
collaborators: TCollaborator[];
currentUserId: number;
};

const MAX_VISIBLE_AVATARS = 3;

export const CollaboratorAvatars = ({ collaborators, currentUserId }: TCollaboratorAvatars) => {
const overflowCount = Math.max(0, collaborators.length - MAX_VISIBLE_AVATARS);
const visibleCollaborators = collaborators.slice(0, MAX_VISIBLE_AVATARS);

return (
<Popover>
<PopoverTrigger asChild>
<button type="button" className="cursor-pointer border-none bg-transparent p-0">
<AvatarGroup>
{visibleCollaborators.map((collaborator) => (
<Avatar key={collaborator.id} size="s">
{collaborator.avatarUrl && <AvatarImage src={collaborator.avatarUrl} size="s" />}
<AvatarFallback size="s">{collaborator.initial}</AvatarFallback>
</Avatar>
))}
{overflowCount > 0 && <AvatarGroupCount>+{overflowCount}</AvatarGroupCount>}
</AvatarGroup>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={12}
className="z-100 w-64 rounded-xl p-2"
>
<div className="flex flex-col gap-1">
<p className="text-gray-60 px-2 py-1 text-xs">Участники в заметке</p>
{collaborators.map((collaborator) => (
<div
key={collaborator.id}
className="hover:bg-gray-5 flex items-center gap-2 rounded-lg px-2 py-1.5"
>
<Avatar size="s">
{collaborator.avatarUrl && <AvatarImage src={collaborator.avatarUrl} size="s" />}
<AvatarFallback size="s">{collaborator.initial}</AvatarFallback>
</Avatar>
<span className="flex-1 truncate text-sm text-gray-100">
{collaborator.id === currentUserId ? 'Вы' : collaborator.userName}
</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
);
};
16 changes: 16 additions & 0 deletions packages/pages.notes/src/ui/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { useMemo } from 'react';
import { useParams, useRouter } from '@tanstack/react-router';
import {
useCurrentUser,
Expand All @@ -10,9 +11,13 @@ import { Skeleton } from 'common.ui';
import { EditableTitle } from './EditableTitle';
import { Button } from '@xipkg/button';
import { ArrowLeft } from '@xipkg/icons';
import { useCollaborators } from 'modules.editor';
import { CollaboratorAvatars } from './CollaboratorAvatars';
import { getAvatarUrlByUserId } from '../utils';

export const Header = () => {
const { classroomId, noteId, materialId } = useParams({ strict: false });
const { collaborators } = useCollaborators();
const router = useRouter();

const { data: user } = useCurrentUser();
Expand Down Expand Up @@ -54,6 +59,16 @@ export const Header = () => {
}
};

const collaboratorsWithAvatars = useMemo(
() =>
collaborators.map((collaborator) => ({
...collaborator,
avatarUrl: getAvatarUrlByUserId(collaborator.id),
initial: collaborator.userName.charAt(0).toUpperCase(),
})),
[collaborators],
);

return (
<div className="bg-gray-0 border-gray-10 sticky top-0 z-50 flex h-[56px] min-h-[56px] w-full rounded-2xl border px-2">
<div className="flex w-full items-center justify-between">
Expand All @@ -73,6 +88,7 @@ export const Header = () => {
<EditableTitle title={material.name} materialId={materialIdValue} isTutor={isTutor} />
)}
</div>
<CollaboratorAvatars collaborators={collaboratorsWithAvatars} currentUserId={user.id} />
</div>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/pages.notes/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { env } from 'common.env';

export const getAvatarUrlByUserId = (id: number): string | undefined => {
return `${env.VITE_SERVER_URL_BACKEND}/files/users/${id}/avatar.webp`;
};
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading