From d66fa0f7842e1c39c5302e68001059ca6bf74650 Mon Sep 17 00:00:00 2001 From: AHTu21 Date: Sun, 24 May 2026 18:09:29 +0300 Subject: [PATCH 1/2] feat: collaborative sticker text editing (Yjs + Socket.IO) TipTap Collaboration with in-memory Y.Doc relay on server, awareness cursors, and collab provider when editing a card. Requires socket online; save on blur unchanged. Co-authored-by: Cursor --- CHANGELOG.md | 3 + client/package.json | 6 +- client/src/components/StickerTipTapField.tsx | 103 ++++++--- client/src/index.css | 24 ++ .../stickerCollab/StickerCollabProvider.ts | 104 +++++++++ client/src/lib/stickerCollab/binary.ts | 15 ++ client/src/lib/stickerCollab/collabUser.ts | 13 ++ .../src/lib/stickerCollab/useStickerCollab.ts | 31 +++ client/src/lib/stickerTipTap/extensions.ts | 24 +- client/src/pages/RoomPage.tsx | 50 ++++- docs/EPIC_STICKER_COLLAB.md | 14 +- docs/STICKER_EDITOR_BACKLOG.md | 2 +- docs/TIPTAP_FUTURE.md | 11 +- package-lock.json | 146 +++++++++++- server/package.json | 7 +- server/src/socket.ts | 3 + server/src/stickerCollabSocket.ts | 208 ++++++++++++++++++ 17 files changed, 711 insertions(+), 53 deletions(-) create mode 100644 client/src/lib/stickerCollab/StickerCollabProvider.ts create mode 100644 client/src/lib/stickerCollab/binary.ts create mode 100644 client/src/lib/stickerCollab/collabUser.ts create mode 100644 client/src/lib/stickerCollab/useStickerCollab.ts create mode 100644 server/src/stickerCollabSocket.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index df89a38..e13b9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ _Черновик до следующего релиза; в окне «О пр ### Доска и синхронизация +- **Совместное редактирование текста стикера:** если два участника открыли **правку одного и того же** стикера, изменения и **курсоры** синхронизируются по WebSocket (Yjs); сохранение по blur по-прежнему пишет текст на сервер. Отдельно от превью **перемещения** стикеров на доске. +- **Текст стикера на сервере:** при сохранении дополнительно хранится **JSON-документ** редактора (`textDoc`); HTML в карточке — для просмотра на доске и отчётов. Старые стикеры без JSON работают как раньше. + - **Совместная работа в реальном времени:** пока вы **двигаете или тянете за угол** блок, стикер, картинку на плоскости, таймер или рамку схемы, остальные участники комнаты видят это **сразу** (отдельный канал по сокету без записи каждого пикселя в БД). После **отпускания мыши** раскладка по-прежнему сохраняется на сервер; при одновременных правках клиент **сливает конфликт** и подтягивает актуальный снимок. - **Новые элементы у курсора:** добавление **блока** и **стикера** с панели, **шаблон стикера**, **картинка** с диска или из буфера, **таймер** и **рамки пресета** появляются в точке **курсора над доской**; если вы ещё не водили мышью по доске — в **центре видимой области**. - **Зум и размер:** при сильном увеличении или отдалении плоскости новые стикеры, блоки и картинки **не становятся нечитаемо мелкими или громоздкими** — их размер в координатах доски подстраивается так, чтобы **на экране** оставаться примерно того же визуального масштаба при разном зуме. diff --git a/client/package.json b/client/package.json index a84c9cc..3973a24 100644 --- a/client/package.json +++ b/client/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "@tiptap/extension-collaboration": "^3.23.6", + "@tiptap/extension-collaboration-cursor": "^3.0.0", "@tiptap/extension-color": "^3.23.6", "@tiptap/extension-font-family": "^3.23.6", "@tiptap/extension-highlight": "^3.23.6", @@ -31,7 +33,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", diff --git a/client/src/components/StickerTipTapField.tsx b/client/src/components/StickerTipTapField.tsx index b27a888..1c47070 100644 --- a/client/src/components/StickerTipTapField.tsx +++ b/client/src/components/StickerTipTapField.tsx @@ -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; @@ -20,9 +28,21 @@ type Props = { onBlur?: (cardId: string, e: React.FocusEvent) => void; }; +function seedStickerEditor( + editor: NonNullable>, + seed: string | JSONContent, +) { + if (isStickerTextDoc(seed)) { + editor.commands.setContent(seed, { emitUpdate: false }); + return; + } + editor.commands.setContent(seed?.trim() ? seed : "

", { emitUpdate: false }); +} + export function StickerTipTapField({ cardId, initialContent, + collab, className = "", style, spellCheck = true, @@ -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 - : "

", - 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 + : "

", + 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 ? collab.provider : null, cardId], + ); + + useEffect(() => { + seededCollabRef.current = false; + }, [cardId, collab?.provider]); useEffect(() => { if (!editor) return; @@ -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); @@ -83,7 +128,7 @@ export function StickerTipTapField({ } const next = initialContent?.trim() ? initialContent : "

"; if (next !== editor.getHTML()) editor.commands.setContent(next, { emitUpdate: false }); - }, [cardId, initialContent, editor]); + }, [cardId, initialContent, editor, collab]); if (!editor) return null; diff --git a/client/src/index.css b/client/src/index.css index 11de344..28059e5 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; +} diff --git a/client/src/lib/stickerCollab/StickerCollabProvider.ts b/client/src/lib/stickerCollab/StickerCollabProvider.ts new file mode 100644 index 0000000..10fa8eb --- /dev/null +++ b/client/src/lib/stickerCollab/StickerCollabProvider.ts @@ -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(); + } +} diff --git a/client/src/lib/stickerCollab/binary.ts b/client/src/lib/stickerCollab/binary.ts new file mode 100644 index 0000000..4bd8d49 --- /dev/null +++ b/client/src/lib/stickerCollab/binary.ts @@ -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; +} diff --git a/client/src/lib/stickerCollab/collabUser.ts b/client/src/lib/stickerCollab/collabUser.ts new file mode 100644 index 0000000..aad0129 --- /dev/null +++ b/client/src/lib/stickerCollab/collabUser.ts @@ -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) || "Участник"; +} diff --git a/client/src/lib/stickerCollab/useStickerCollab.ts b/client/src/lib/stickerCollab/useStickerCollab.ts new file mode 100644 index 0000000..bfb45c8 --- /dev/null +++ b/client/src/lib/stickerCollab/useStickerCollab.ts @@ -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(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]); +} diff --git a/client/src/lib/stickerTipTap/extensions.ts b/client/src/lib/stickerTipTap/extensions.ts index 230bb9c..5d1240d 100644 --- a/client/src/lib/stickerTipTap/extensions.ts +++ b/client/src/lib/stickerTipTap/extensions.ts @@ -1,4 +1,7 @@ +import Collaboration from "@tiptap/extension-collaboration"; +import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { Color } from "@tiptap/extension-color"; +import type { Doc } from "yjs"; import FontFamily from "@tiptap/extension-font-family"; import Highlight from "@tiptap/extension-highlight"; import HorizontalRule from "@tiptap/extension-horizontal-rule"; @@ -15,15 +18,34 @@ import { TextStyle } from "@tiptap/extension-text-style"; import Underline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; import { MENTION_CLASS } from "../stickerMentions"; +import type { StickerCollabProvider } from "../stickerCollab/StickerCollabProvider"; import { StickerMentionNode } from "./mentionExtension"; +export type StickerTipTapCollabOptions = { + document: Doc; + provider: StickerCollabProvider; + user: { name: string; color: string }; +}; + /** MIT-расширения TipTap для стикера Retrogen. */ -export function createStickerTipTapExtensions() { +export function createStickerTipTapExtensions(collab?: StickerTipTapCollabOptions) { return [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, horizontalRule: false, + undoRedo: collab ? false : undefined, }), + ...(collab + ? [ + Collaboration.configure({ + document: collab.document, + }), + CollaborationCursor.configure({ + provider: collab.provider, + user: collab.user, + }), + ] + : []), Underline, Subscript, Superscript, diff --git a/client/src/pages/RoomPage.tsx b/client/src/pages/RoomPage.tsx index 3084e91..1d6df56 100644 --- a/client/src/pages/RoomPage.tsx +++ b/client/src/pages/RoomPage.tsx @@ -52,6 +52,8 @@ import { exportStickerCardToPng } from "../lib/stickerPngExport"; import { StickerConnectionsLayer } from "../components/StickerConnectionsLayer"; import { StickerTipTapFieldLazy } from "../components/StickerTipTapFieldLazy"; import type { StickerEditorApi } from "../lib/stickerTipTap/api"; +import { stickerCollabUserColor, stickerCollabUserLabel } from "../lib/stickerCollab/collabUser"; +import { useStickerCollab } from "../lib/stickerCollab/useStickerCollab"; import { stickerCardEditorContent } from "../lib/stickerTextDoc"; import { buildMentionCandidatesFromRoom, @@ -3002,6 +3004,21 @@ export function RoomPage() { [authMe, guestName], ); + const stickerCollabUser = useMemo( + () => ({ + name: stickerCollabUserLabel(actorDisplayName, participantKey), + color: stickerCollabUserColor(participantKey || guestName || "local"), + }), + [actorDisplayName, participantKey, guestName], + ); + + const stickerCollabProvider = useStickerCollab( + socket, + slug, + editingCardId, + editingCardId && socketSessionLive ? stickerCollabUser : null, + ); + const mentionCandidatesLive = useMemo(() => { if (!room || !mentionSuggest) return [] as MentionCandidate[]; return filterMentionCandidates( @@ -3531,16 +3548,19 @@ export function RoomPage() { window.setTimeout(() => { const api = editorRefs.current[id]; if (!api) return; - const currentDraft = editDrafts[id] ?? room?.cards.find((c) => c.id === id)?.text ?? ""; - const normalized = currentDraft?.trim() ? currentDraft : "

"; - if (api.getHtml() !== normalized) { - api.setHtml(normalized); + if (!stickerCollabProvider) { + const currentDraft = editDrafts[id] ?? room?.cards.find((c) => c.id === id)?.text ?? ""; + const normalized = currentDraft?.trim() ? currentDraft : "

"; + if (api.getHtml() !== normalized) { + api.setHtml(normalized); + } } api.focus(); }, 0); // Синхронизируем DOM и каретку только при входе в режим редактирования, иначе каждое обновление // черновика сбрасывало выделение и ломало тулбар/набор текста. - }, [editingCardId]); + // При Yjs collab содержимое приходит из CRDT, не из editDrafts. + }, [editingCardId, stickerCollabProvider]); useEffect(() => { setStickerEditorMono(false); @@ -5978,8 +5998,18 @@ export function RoomPage() {
=16.0.0", + "npm": ">=8.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3335,6 +3388,15 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -3419,6 +3481,26 @@ "node": ">=6" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -5442,6 +5524,49 @@ "node": ">=0.4.0" } }, + "node_modules/y-prosemirror": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", + "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", + "peer": true, + "dependencies": { + "lib0": "^0.2.109" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -5484,6 +5609,22 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "server": { "version": "0.8.0", "dependencies": { @@ -5495,7 +5636,8 @@ "fastify": "^5.2.1", "jose": "^5.9.6", "nanoid": "^5.0.9", - "socket.io": "^4.8.1" + "socket.io": "^4.8.1", + "yjs": "^13.6.30" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/server/package.json b/server/package.json index e1502df..a66c718 100644 --- a/server/package.json +++ b/server/package.json @@ -16,15 +16,16 @@ "reset-password": "tsx scripts/reset-user-password.ts" }, "dependencies": { - "bcryptjs": "^2.4.3", - "dotenv": "^16.4.7", "@fastify/cors": "^10.0.2", "@fastify/static": "^8.0.4", "@prisma/client": "^6.1.0", + "bcryptjs": "^2.4.3", + "dotenv": "^16.4.7", "fastify": "^5.2.1", "jose": "^5.9.6", "nanoid": "^5.0.9", - "socket.io": "^4.8.1" + "socket.io": "^4.8.1", + "yjs": "^13.6.30" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/server/src/socket.ts b/server/src/socket.ts index d01d2e3..56d463e 100644 --- a/server/src/socket.ts +++ b/server/src/socket.ts @@ -1,6 +1,7 @@ import type { Server as HttpServer } from "node:http"; import { Server } from "socket.io"; import { roomAccessStatus } from "./roomAccess.js"; +import { registerStickerCollabHandlers } from "./stickerCollabSocket.js"; function reqLikeFromHandshake(socket: { handshake: { headers: Record; auth?: Record }; @@ -19,6 +20,8 @@ export function attachSocket(httpServer: HttpServer) { path: "/socket.io", }); + registerStickerCollabHandlers(io); + io.on("connection", (socket) => { socket.on("join", async (slug: string, ack?: (err: Error | null) => void) => { if (typeof slug !== "string" || !slug) { diff --git a/server/src/stickerCollabSocket.ts b/server/src/stickerCollabSocket.ts new file mode 100644 index 0000000..2261a3c --- /dev/null +++ b/server/src/stickerCollabSocket.ts @@ -0,0 +1,208 @@ +import type { Server, Socket } from "socket.io"; +import * as Y from "yjs"; +import { prisma } from "./lib/prisma.js"; +import { roomAccessStatus } from "./roomAccess.js"; + +const MAX_UPDATE_B64_LEN = 280_000; +const MAX_UPDATES_PER_SEC = 60; + +type CollabSession = { + doc: Y.Doc; + clients: number; +}; + +const sessions = new Map(); +const updateBuckets = new Map(); + +function collabKey(slug: string, cardId: string) { + return `${slug}:${cardId}`; +} + +function roomChannel(key: string) { + return `collab:${key}`; +} + +function uint8ToB64(u8: Uint8Array): string { + return Buffer.from(u8).toString("base64"); +} + +function b64ToUint8(b64: string): Uint8Array | null { + if (typeof b64 !== "string" || !b64) return null; + try { + const buf = Buffer.from(b64, "base64"); + if (buf.length > MAX_UPDATE_B64_LEN) return null; + return new Uint8Array(buf); + } catch { + return null; + } +} + +function rateLimit(socketId: string): boolean { + const now = Date.now(); + let b = updateBuckets.get(socketId); + if (!b || now >= b.resetAt) { + b = { count: 0, resetAt: now + 1000 }; + updateBuckets.set(socketId, b); + } + b.count += 1; + return b.count <= MAX_UPDATES_PER_SEC; +} + +function getSocketCollabKeys(socket: Socket): Set { + const data = socket.data as { stickerCollabKeys?: Set }; + if (!data.stickerCollabKeys) data.stickerCollabKeys = new Set(); + return data.stickerCollabKeys; +} + +function leaveCollab(socket: Socket, key: string) { + const keys = getSocketCollabKeys(socket); + if (!keys.has(key)) return; + keys.delete(key); + socket.leave(roomChannel(key)); + const session = sessions.get(key); + if (!session) return; + session.clients = Math.max(0, session.clients - 1); + if (session.clients === 0) { + session.doc.destroy(); + sessions.delete(key); + } +} + +function reqLikeFromHandshake(socket: Socket) { + const h: Record = { ...socket.handshake.headers }; + const auth = socket.handshake.auth as { roomUnlockToken?: string; token?: string } | undefined; + if (auth?.roomUnlockToken) h["x-room-unlock-token"] = auth.roomUnlockToken; + if (auth?.token) h.authorization = `Bearer ${auth.token}`; + return { headers: h }; +} + +async function assertCollabAccess( + socket: Socket, + slug: string, +): Promise<"ok" | "not_found" | "password_required" | "room_ended"> { + if (typeof slug !== "string" || !slug) return "not_found"; + const st = await roomAccessStatus(reqLikeFromHandshake(socket), slug); + if (st !== "ok") return st; + const room = await prisma.room.findUnique({ + where: { slug }, + select: { status: true }, + }); + if (!room) return "not_found"; + if (room.status === "ended") return "room_ended"; + return "ok"; +} + +export function registerStickerCollabHandlers(io: Server) { + io.on("connection", (socket) => { + socket.on("disconnect", () => { + const keys = [...getSocketCollabKeys(socket)]; + for (const key of keys) leaveCollab(socket, key); + updateBuckets.delete(socket.id); + }); + + socket.on( + "stickerCollab:join", + async ( + payload: { slug?: string; cardId?: string }, + ack?: (err: { message: string } | null) => void, + ) => { + const slug = payload?.slug; + const cardId = payload?.cardId; + if (typeof slug !== "string" || !slug || typeof cardId !== "string" || !cardId) { + ack?.({ message: "invalid payload" }); + return; + } + const access = await roomAccessStatus(reqLikeFromHandshake(socket), slug); + if (access === "not_found") { + ack?.({ message: "not_found" }); + return; + } + if (access === "password_required") { + ack?.({ message: "room_password_required" }); + return; + } + const room = await prisma.room.findUnique({ + where: { slug }, + select: { status: true, id: true }, + }); + if (!room) { + ack?.({ message: "not_found" }); + return; + } + if (room.status === "ended") { + ack?.({ message: "room_ended" }); + return; + } + const card = await prisma.card.findFirst({ + where: { id: cardId, roomId: room.id }, + select: { id: true }, + }); + if (!card) { + ack?.({ message: "card_not_found" }); + return; + } + + const key = collabKey(slug, cardId); + let session = sessions.get(key); + if (!session) { + session = { doc: new Y.Doc(), clients: 0 }; + sessions.set(key, session); + } + session.clients += 1; + getSocketCollabKeys(socket).add(key); + await socket.join(roomChannel(key)); + + const state = uint8ToB64(Y.encodeStateAsUpdate(session.doc)); + socket.emit("stickerCollab:state", { cardId, update: state }); + ack?.(null); + }, + ); + + socket.on( + "stickerCollab:update", + async (payload: { slug?: string; cardId?: string; update?: string }) => { + if (!rateLimit(socket.id)) return; + const slug = payload?.slug; + const cardId = payload?.cardId; + if (typeof slug !== "string" || typeof cardId !== "string") return; + const key = collabKey(slug, cardId); + if (!getSocketCollabKeys(socket).has(key)) return; + const bin = b64ToUint8(payload?.update ?? ""); + if (!bin) return; + const access = await assertCollabAccess(socket, slug); + if (access !== "ok") return; + const session = sessions.get(key); + if (!session) return; + Y.applyUpdate(session.doc, bin, socket.id); + socket.to(roomChannel(key)).emit("stickerCollab:sync", { cardId, update: payload!.update }); + }, + ); + + socket.on( + "stickerCollab:awareness", + async (payload: { slug?: string; cardId?: string; update?: string }) => { + if (!rateLimit(socket.id)) return; + const slug = payload?.slug; + const cardId = payload?.cardId; + if (typeof slug !== "string" || typeof cardId !== "string") return; + const key = collabKey(slug, cardId); + if (!getSocketCollabKeys(socket).has(key)) return; + if (!payload?.update || payload.update.length > MAX_UPDATE_B64_LEN) return; + const access = await assertCollabAccess(socket, slug); + if (access !== "ok") return; + socket.to(roomChannel(key)).emit("stickerCollab:awareness", { + cardId, + update: payload.update, + socketId: socket.id, + }); + }, + ); + + socket.on("stickerCollab:leave", (payload: { slug?: string; cardId?: string }) => { + const slug = payload?.slug; + const cardId = payload?.cardId; + if (typeof slug !== "string" || typeof cardId !== "string") return; + leaveCollab(socket, collabKey(slug, cardId)); + }); + }); +} From 4af7d7f2f3bb9ea0b37b00c7956272b75ec415a6 Mon Sep 17 00:00:00 2001 From: AHTu21 Date: Sun, 24 May 2026 18:15:37 +0300 Subject: [PATCH 2/2] fix: sticker editor crash on edit (collab cursor compat) Remove incompatible CollaborationCursor; disable duplicate link/underline in StarterKit; wait for Yjs provider before mounting collab editor. Co-authored-by: Cursor --- client/package.json | 1 - client/src/components/StickerTipTapField.tsx | 2 +- client/src/lib/stickerTipTap/extensions.ts | 7 +--- client/src/pages/RoomPage.tsx | 26 +++++++++++-- docs/EPIC_STICKER_COLLAB.md | 2 +- package-lock.json | 39 -------------------- 6 files changed, 26 insertions(+), 51 deletions(-) diff --git a/client/package.json b/client/package.json index 3973a24..fe4a213 100644 --- a/client/package.json +++ b/client/package.json @@ -10,7 +10,6 @@ }, "dependencies": { "@tiptap/extension-collaboration": "^3.23.6", - "@tiptap/extension-collaboration-cursor": "^3.0.0", "@tiptap/extension-color": "^3.23.6", "@tiptap/extension-font-family": "^3.23.6", "@tiptap/extension-highlight": "^3.23.6", diff --git a/client/src/components/StickerTipTapField.tsx b/client/src/components/StickerTipTapField.tsx index 1c47070..700442f 100644 --- a/client/src/components/StickerTipTapField.tsx +++ b/client/src/components/StickerTipTapField.tsx @@ -104,7 +104,7 @@ export function StickerTipTapField({ onBlur?.(cardId, event as unknown as React.FocusEvent); }, }, - [collab ? collab.provider : null, cardId], + [collab?.provider ?? null, cardId], ); useEffect(() => { diff --git a/client/src/lib/stickerTipTap/extensions.ts b/client/src/lib/stickerTipTap/extensions.ts index 5d1240d..8636e17 100644 --- a/client/src/lib/stickerTipTap/extensions.ts +++ b/client/src/lib/stickerTipTap/extensions.ts @@ -1,5 +1,4 @@ import Collaboration from "@tiptap/extension-collaboration"; -import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { Color } from "@tiptap/extension-color"; import type { Doc } from "yjs"; import FontFamily from "@tiptap/extension-font-family"; @@ -33,6 +32,8 @@ export function createStickerTipTapExtensions(collab?: StickerTipTapCollabOption StarterKit.configure({ heading: { levels: [1, 2, 3] }, horizontalRule: false, + link: false, + underline: false, undoRedo: collab ? false : undefined, }), ...(collab @@ -40,10 +41,6 @@ export function createStickerTipTapExtensions(collab?: StickerTipTapCollabOption Collaboration.configure({ document: collab.document, }), - CollaborationCursor.configure({ - provider: collab.provider, - user: collab.user, - }), ] : []), Underline, diff --git a/client/src/pages/RoomPage.tsx b/client/src/pages/RoomPage.tsx index 1d6df56..adff35c 100644 --- a/client/src/pages/RoomPage.tsx +++ b/client/src/pages/RoomPage.tsx @@ -3019,6 +3019,10 @@ export function RoomPage() { editingCardId && socketSessionLive ? stickerCollabUser : null, ); + /** При онлайн-сокете ждём Yjs-провайдер, чтобы не монтировать solo-редактор и не пересоздавать TipTap. */ + const stickerEditReady = !socketSessionLive || stickerCollabProvider !== null; + const stickerEditCollab = Boolean(socketSessionLive && stickerCollabProvider); + const mentionCandidatesLive = useMemo(() => { if (!room || !mentionSuggest) return [] as MentionCandidate[]; return filterMentionCandidates( @@ -5997,12 +6001,18 @@ export function RoomPage() { {editingCardId === c.id ? (
+ {!stickerEditReady ? ( +
+ ) : ( + )}
) : ( @@ -6270,12 +6281,18 @@ export function RoomPage() { {editingCardId === c.id ? (
+ {!stickerEditReady ? ( +
+ ) : ( + )}
) : ( diff --git a/docs/EPIC_STICKER_COLLAB.md b/docs/EPIC_STICKER_COLLAB.md index 59d5093..f55f1f2 100644 --- a/docs/EPIC_STICKER_COLLAB.md +++ b/docs/EPIC_STICKER_COLLAB.md @@ -34,7 +34,7 @@ client → server: stickerCollab:leave { slug, cardId } 2. [x] Сервер: `stickerCollab:join|update|awareness|leave`, in-memory `Y.Doc`, rate limit 3. [x] Клиент: `StickerCollabProvider` + TipTap Collaboration при `editingCardId` 4. [x] При collab не перезаписываем редактор из `editDrafts` при входе в edit -5. [x] UI: `CollaborationCursor`, цвет по `participantKey` +5. [ ] UI: чужие курсоры — `CollaborationCursor@3.0` несовместим с `@tiptap/extension-collaboration@3.23` (отложено) 6. [ ] E2E: два браузера, один стикер (ручной QA) ## Риски diff --git a/package-lock.json b/package-lock.json index 37fe23c..46f9868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "version": "0.8.0", "dependencies": { "@tiptap/extension-collaboration": "^3.23.6", - "@tiptap/extension-collaboration-cursor": "^3.0.0", "@tiptap/extension-color": "^3.23.6", "@tiptap/extension-font-family": "^3.23.6", "@tiptap/extension-highlight": "^3.23.6", @@ -1792,20 +1791,6 @@ "yjs": "^13" } }, - "node_modules/@tiptap/extension-collaboration-cursor": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-cursor/-/extension-collaboration-cursor-3.0.0.tgz", - "integrity": "sha512-GrH80m/BShJJFJVjLeom8JhM4AoB1SyWL6bFgaiN1mHrya7kkdKWz/72LNG7VNEZz1qXPSlM/LdosIxlRFJAQQ==", - "deprecated": "There are no breaking changes in this packages, we meant to release 2.5.0", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.0.0", - "y-prosemirror": "^1.2.6" - } - }, "node_modules/@tiptap/extension-color": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.23.6.tgz", @@ -5524,30 +5509,6 @@ "node": ">=0.4.0" } }, - "node_modules/y-prosemirror": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", - "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", - "peer": true, - "dependencies": { - "lib0": "^0.2.109" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "prosemirror-model": "^1.7.1", - "prosemirror-state": "^1.2.3", - "prosemirror-view": "^1.9.10", - "y-protocols": "^1.0.1", - "yjs": "^13.5.38" - } - }, "node_modules/y-protocols": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",