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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ _Черновик до следующего релиза; в окне «О пр

### Доска и синхронизация

- **Совместное редактирование текста стикера:** если два участника открыли **правку одного и того же** стикера, изменения и **курсоры** синхронизируются по WebSocket (Yjs); сохранение по blur по-прежнему пишет текст на сервер. Отдельно от превью **перемещения** стикеров на доске.
- **Текст стикера на сервере:** при сохранении дополнительно хранится **JSON-документ** редактора (`textDoc`); HTML в карточке — для просмотра на доске и отчётов. Старые стикеры без JSON работают как раньше.

- **Совместная работа в реальном времени:** пока вы **двигаете или тянете за угол** блок, стикер, картинку на плоскости, таймер или рамку схемы, остальные участники комнаты видят это **сразу** (отдельный канал по сокету без записи каждого пикселя в БД). После **отпускания мыши** раскладка по-прежнему сохраняется на сервер; при одновременных правках клиент **сливает конфликт** и подтягивает актуальный снимок.
- **Новые элементы у курсора:** добавление **блока** и **стикера** с панели, **шаблон стикера**, **картинка** с диска или из буфера, **таймер** и **рамки пресета** появляются в точке **курсора над доской**; если вы ещё не водили мышью по доске — в **центре видимой области**.
- **Зум и размер:** при сильном увеличении или отдалении плоскости новые стикеры, блоки и картинки **не становятся нечитаемо мелкими или громоздкими** — их размер в координатах доски подстраивается так, чтобы **на экране** оставаться примерно того же визуального масштаба при разном зуме.
Expand Down
5 changes: 4 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-collaboration": "^3.23.6",
"@tiptap/extension-color": "^3.23.6",
"@tiptap/extension-font-family": "^3.23.6",
"@tiptap/extension-highlight": "^3.23.6",
Expand All @@ -31,7 +32,9 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"socket.io-client": "^4.8.1"
"socket.io-client": "^4.8.1",
"y-protocols": "^1.0.7",
"yjs": "^13.6.30"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
Expand Down
103 changes: 74 additions & 29 deletions client/src/components/StickerTipTapField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import type { JSONContent } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import { useEffect, useRef } from "react";
import { isStickerTextDoc } from "../lib/stickerTextDoc";
import type { StickerCollabProvider } from "../lib/stickerCollab/StickerCollabProvider";
import { createStickerEditorApi, type StickerEditorApi } from "../lib/stickerTipTap/api";
import { createStickerTipTapExtensions } from "../lib/stickerTipTap/extensions";

export type StickerTipTapCollabProps = {
provider: StickerCollabProvider;
user: { name: string; color: string };
seedContent: string | JSONContent;
};

type Props = {
cardId: string;
/** HTML-черновик или JSON-документ с сервера (`textDoc`). */
/** HTML-черновик или JSON-документ с сервера (`textDoc`). Игнорируется при `collab`. */
initialContent: string | JSONContent;
collab?: StickerTipTapCollabProps;
className?: string;
style?: React.CSSProperties;
spellCheck?: boolean;
Expand All @@ -20,9 +28,21 @@ type Props = {
onBlur?: (cardId: string, e: React.FocusEvent) => void;
};

function seedStickerEditor(
editor: NonNullable<ReturnType<typeof useEditor>>,
seed: string | JSONContent,
) {
if (isStickerTextDoc(seed)) {
editor.commands.setContent(seed, { emitUpdate: false });
return;
}
editor.commands.setContent(seed?.trim() ? seed : "<p></p>", { emitUpdate: false });
}

export function StickerTipTapField({
cardId,
initialContent,
collab,
className = "",
style,
spellCheck = true,
Expand All @@ -35,36 +55,61 @@ export function StickerTipTapField({
}: Props) {
const onHtmlChangeRef = useRef(onHtmlChange);
onHtmlChangeRef.current = onHtmlChange;
const seededCollabRef = useRef(false);
const collabSeed = collab?.seedContent;

const editor = useEditor({
extensions: createStickerTipTapExtensions(),
content: isStickerTextDoc(initialContent)
? initialContent
: initialContent?.trim()
? initialContent
: "<p></p>",
enableInputRules: true,
enablePasteRules: true,
editorProps: {
attributes: {
class: `sticker-tiptap prose prose-sm max-w-none focus:outline-none ${className}`.trim(),
spellcheck: spellCheck ? "true" : "false",
"data-sticker-editor": "true",
},
handleDOMEvents: {
keydown: (_view, event) => {
onKeyDown?.(cardId, event as unknown as React.KeyboardEvent);
return false;
const editor = useEditor(
{
extensions: createStickerTipTapExtensions(
collab
? {
document: collab.provider.ydoc,
provider: collab.provider,
user: collab.user,
}
: undefined,
),
content: collab
? undefined
: isStickerTextDoc(initialContent)
? initialContent
: initialContent?.trim()
? initialContent
: "<p></p>",
enableInputRules: true,
enablePasteRules: true,
editorProps: {
attributes: {
class: `sticker-tiptap prose prose-sm max-w-none focus:outline-none ${className}`.trim(),
spellcheck: spellCheck ? "true" : "false",
"data-sticker-editor": "true",
},
handleDOMEvents: {
keydown: (_view, event) => {
onKeyDown?.(cardId, event as unknown as React.KeyboardEvent);
return false;
},
},
},
onCreate: ({ editor: ed }) => {
if (!collab || seededCollabRef.current) return;
if (!ed.isEmpty) return;
seedStickerEditor(ed, collabSeed ?? initialContent);
seededCollabRef.current = true;
},
onUpdate: ({ editor: ed }) => {
onHtmlChangeRef.current(cardId, ed.getHTML());
},
onBlur: ({ event }) => {
onBlur?.(cardId, event as unknown as React.FocusEvent);
},
},
onUpdate: ({ editor: ed }) => {
onHtmlChangeRef.current(cardId, ed.getHTML());
},
onBlur: ({ event }) => {
onBlur?.(cardId, event as unknown as React.FocusEvent);
},
});
[collab?.provider ?? null, cardId],
);

useEffect(() => {
seededCollabRef.current = false;
}, [cardId, collab?.provider]);

useEffect(() => {
if (!editor) return;
Expand All @@ -74,7 +119,7 @@ export function StickerTipTapField({
}, [editor, cardId, onRegister]);

useEffect(() => {
if (!editor || editor.isFocused) return;
if (!editor || collab || editor.isFocused) return;
if (isStickerTextDoc(initialContent)) {
const cur = JSON.stringify(editor.getJSON());
const next = JSON.stringify(initialContent);
Expand All @@ -83,7 +128,7 @@ export function StickerTipTapField({
}
const next = initialContent?.trim() ? initialContent : "<p></p>";
if (next !== editor.getHTML()) editor.commands.setContent(next, { emitUpdate: false });
}, [cardId, initialContent, editor]);
}, [cardId, initialContent, editor, collab]);

if (!editor) return null;

Expand Down
24 changes: 24 additions & 0 deletions client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,27 @@ html:is(.corners-rounded, .corners-sharp) :where(
font-size: 0.88em;
line-height: 1.35;
}

/* Совместное редактирование стикера (TipTap CollaborationCursor) */
.sticker-tiptap .collaboration-cursor__caret {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 2px solid currentColor;
pointer-events: none;
}

.sticker-tiptap .collaboration-cursor__label {
position: absolute;
top: -1.35em;
left: -1px;
padding: 1px 4px;
border-radius: 3px 3px 3px 0;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
color: #fff;
user-select: none;
pointer-events: none;
}
104 changes: 104 additions & 0 deletions client/src/lib/stickerCollab/StickerCollabProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Socket } from "socket.io-client";
import * as Y from "yjs";
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
import { base64ToUint8, uint8ToBase64 } from "./binary";

const LOCAL_ORIGIN = "local-sticker-collab";

export type StickerCollabUser = {
name: string;
color: string;
};

/** Socket.IO + Yjs для одного стикера (TipTap Collaboration / Awareness). */
export class StickerCollabProvider {
readonly ydoc = new Y.Doc();
readonly awareness: Awareness;

private readonly socket: Socket;
private readonly slug: string;
private readonly cardId: string;
private destroyed = false;

private readonly onDocUpdate = (update: Uint8Array, origin: unknown) => {
if (this.destroyed || origin === LOCAL_ORIGIN) return;
this.socket.emit("stickerCollab:update", {
slug: this.slug,
cardId: this.cardId,
update: uint8ToBase64(update),
});
};

private readonly onAwarenessUpdate = (
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: unknown,
) => {
if (this.destroyed || origin === LOCAL_ORIGIN) return;
const changed = added.concat(updated, removed);
if (!changed.length) return;
const encoded = encodeAwarenessUpdate(this.awareness, changed);
this.socket.emit("stickerCollab:awareness", {
slug: this.slug,
cardId: this.cardId,
update: uint8ToBase64(encoded),
});
};

private readonly onRemoteDoc = (msg: { cardId?: string; update?: string }) => {
if (this.destroyed || msg.cardId !== this.cardId || !msg.update) return;
try {
Y.applyUpdate(this.ydoc, base64ToUint8(msg.update), LOCAL_ORIGIN);
} catch {
/* ignore corrupt */
}
};

private readonly onRemoteState = (msg: { cardId?: string; update?: string }) => {
if (this.destroyed || msg.cardId !== this.cardId || !msg.update) return;
try {
Y.applyUpdate(this.ydoc, base64ToUint8(msg.update), LOCAL_ORIGIN);
} catch {
/* ignore */
}
};

private readonly onRemoteAwareness = (msg: { cardId?: string; update?: string }) => {
if (this.destroyed || msg.cardId !== this.cardId || !msg.update) return;
try {
applyAwarenessUpdate(this.awareness, base64ToUint8(msg.update), LOCAL_ORIGIN);
} catch {
/* ignore */
}
};

constructor(socket: Socket, slug: string, cardId: string, user: StickerCollabUser) {
this.socket = socket;
this.slug = slug;
this.cardId = cardId;
this.awareness = new Awareness(this.ydoc);
this.awareness.setLocalStateField("user", user);

this.ydoc.on("update", this.onDocUpdate);
this.awareness.on("update", this.onAwarenessUpdate);
socket.on("stickerCollab:sync", this.onRemoteDoc);
socket.on("stickerCollab:state", this.onRemoteState);
socket.on("stickerCollab:awareness", this.onRemoteAwareness);

socket.emit("stickerCollab:join", { slug, cardId }, (err: { message?: string } | null) => {
if (err?.message) console.warn("[stickerCollab:join]", err.message);
});
}

destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.socket.emit("stickerCollab:leave", { slug: this.slug, cardId: this.cardId });
this.socket.off("stickerCollab:sync", this.onRemoteDoc);
this.socket.off("stickerCollab:state", this.onRemoteState);
this.socket.off("stickerCollab:awareness", this.onRemoteAwareness);
removeAwarenessStates(this.awareness, [this.ydoc.clientID], LOCAL_ORIGIN);
this.awareness.destroy();
this.ydoc.off("update", this.onDocUpdate);
this.ydoc.destroy();
}
}
15 changes: 15 additions & 0 deletions client/src/lib/stickerCollab/binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function uint8ToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}

export function base64ToUint8(b64: string): Uint8Array {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
13 changes: 13 additions & 0 deletions client/src/lib/stickerCollab/collabUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Стабильный цвет курсора по ключу участника. */
export function stickerCollabUserColor(key: string): string {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
const hue = Math.abs(h) % 360;
return `hsl(${hue} 70% 45%)`;
}

export function stickerCollabUserLabel(displayName: string, participantKey: string): string {
const name = displayName.trim();
if (name) return name;
return participantKey.slice(0, 8) || "Участник";
}
31 changes: 31 additions & 0 deletions client/src/lib/stickerCollab/useStickerCollab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, useMemo, useState } from "react";
import type { Socket } from "socket.io-client";
import { StickerCollabProvider, type StickerCollabUser } from "./StickerCollabProvider";

export function useStickerCollab(
socket: Socket | null,
slug: string | undefined,
cardId: string | null,
user: StickerCollabUser | null,
): StickerCollabProvider | null {
const [provider, setProvider] = useState<StickerCollabProvider | null>(null);
const userKey = user ? `${user.name}:${user.color}` : "";

const socketConnected = Boolean(socket?.connected);
const canConnect = Boolean(socketConnected && slug && cardId && user);

useEffect(() => {
if (!canConnect || !socket || !slug || !cardId || !user) {
setProvider(null);
return;
}
const p = new StickerCollabProvider(socket, slug, cardId, user);
setProvider(p);
return () => {
p.destroy();
setProvider(null);
};
}, [canConnect, socketConnected, socket, slug, cardId, userKey]);

return useMemo(() => provider, [provider]);
}
Loading
Loading