/
effective_wiki
/
web-client
Обзор
Документация
Войти
/
effective_wiki
/
web-client
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
src/components/editor/editor.tsx
328 строк
10 KB
Max-Sam231
chore: add some changes for merging versions
22 дек 2025, 05:09
22 дек 2025, 05:09
fbcc461
Код
Авторство
О чём код?
"use client"; import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import Document from "@tiptap/extension-document"; import Text from "@tiptap/extension-text"; import { useAction } from "@reatom/npm-react"; import { Paragraph } from "@/extensions/paragraph/paragraph"; import { Heading } from "@/extensions/heading/heading"; import { BulletList } from "@/extensions/lists/bullet-list"; import { ListItem } from "@/extensions/lists/list-item"; import { TaskList } from "@/extensions/lists/task-list/task-list"; import { TaskItem } from "@/extensions/lists/task-list/task-item"; import Dropcursor from "@tiptap/extension-dropcursor"; import Gapcursor from "@tiptap/extension-gapcursor"; import DragHandle from "@tiptap/extension-drag-handle-react"; import { Button } from "../ui/button"; import { GripVertical, Loader2, Plus } from "lucide-react"; import { OrderList } from "@/extensions/lists/order-list"; import { BlockQuote } from "@/extensions/blockquote/blockquote"; import { SlashCommands } from "@/extensions/slash-command/slash-commands"; import { setEditorElementAction, setEditorInstanceAction } from "@/store/editor.atoms"; import { FloatingToolbar } from "@/extensions/bubble-menu/bubble-menu"; import { BlockContextMenu } from "@/extensions/bubble-menu/block-context-menu"; import Bold from "@tiptap/extension-bold"; import Italic from "@tiptap/extension-italic"; import Underline from "@tiptap/extension-underline"; import Code from "@tiptap/extension-code"; import Highlight from "@tiptap/extension-highlight"; import Strike from "@tiptap/extension-strike"; import History from "@tiptap/extension-history"; import { PlusMenuComponent } from "@/extensions/plus-menu/plus-menu-view"; import { plusActions } from "@/extensions/plus-menu/commands"; import { usePlusMenu } from "@/extensions/plus-menu/use-plus-menu"; import { Columns, ColumnsRow, ColumnsCell } from "@/extensions/columns"; import { BlockGroup } from "@/extensions/block-group"; import { PageLink } from "@/extensions/page-link/page-link"; import { BlockSelection } from "./block-selection"; import { useBlockSelection } from "@/hooks/use-block-selection"; import Collaboration from "@tiptap/extension-collaboration"; import type * as Y from "yjs"; import type { HocuspocusProvider } from "@hocuspocus/provider"; import CollaborationCaret from "@tiptap/extension-collaboration-caret"; import { DatabaseButton } from "@/extensions/database-button/database-button"; interface CollaborationExtensions { ydoc: Y.Doc; provider: HocuspocusProvider; user: { name: string; color: string; id?: string; }; } interface EditorProps { collaborationExtensions: CollaborationExtensions; } export function Editor({ collaborationExtensions }: EditorProps) { const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null); const editorElemRef = useRef(null); const setEditorElem = useAction(setEditorElementAction); const setEditorInstance = useAction(setEditorInstanceAction); const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number; y: number }; }>({ isOpen: false, position: { x: 0, y: 0 }, }); const baseExtensions = [ History, Document, Paragraph, Heading, BulletList, OrderList, TaskList, BlockQuote, ListItem, TaskItem, Text, Bold, Code, Italic, Underline, Strike, Highlight, SlashCommands, Columns.configure({ resizable: false, lastColumnResizable: false, allowTableNodeSelection: false, }), ColumnsRow, ColumnsCell, BlockGroup, PageLink, Dropcursor.configure({ color: "#3b82f6", width: 2, }), Gapcursor, DatabaseButton, ]; const extensionsList = [ ...baseExtensions, Collaboration.configure({ document: collaborationExtensions.ydoc, }), CollaborationCaret.configure({ provider: collaborationExtensions.provider, user: collaborationExtensions.user, render: (user) => { const cursor = document.createElement("span"); cursor.classList.add("collaboration-caret"); cursor.setAttribute("style", `border-color: ${user.color}`); const label = document.createElement("div"); label.classList.add("collaboration-caret__label"); label.setAttribute("style", `background-color: ${user.color}`); label.textContent = user.name; cursor.appendChild(label); return cursor; }, selectionRender: (user) => { return { style: `background-color: ${user.color}20`, }; }, }), ]; const editor = useEditor({ immediatelyRender: false, extensions: extensionsList, content: { type: "doc", content: [], }, // Hocuspocus автоматически синхронизирует изменения // onUpdate не нужен для сохранения editorProps: { attributes: { class: "focus:outline-none", }, }, }); // Typing status для коллаборации useEffect(() => { if (!editor || !collaborationExtensions.provider?.awareness) { return; } const awareness = collaborationExtensions.provider.awareness; const setTyping = (value: boolean) => { awareness.setLocalStateField("isTyping", value); }; const stopTyping = () => { if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = null; } setTyping(false); }; const handleTransaction = () => { setTyping(true); if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } typingTimeoutRef.current = setTimeout(() => { setTyping(false); typingTimeoutRef.current = null; }, 800); }; editor.on("transaction", handleTransaction); editor.on("blur", stopTyping); return () => { editor.off("transaction", handleTransaction); editor.off("blur", stopTyping); stopTyping(); }; }, [editor, collaborationExtensions.provider?.awareness]); const blockSelection = useBlockSelection({ editor, containerRef: editorElemRef }); const { plusMenuState, handlePlusClick, handleSelectPlusAction, handleClosePlusMenu } = usePlusMenu(editor); // Установка элемента редактора useEffect(() => { setEditorElem(editorElemRef); }, [setEditorElem]); useEffect(() => { setEditorInstance(editor); return () => { setEditorInstance(null); }; }, [editor, setEditorInstance]); if (!editor) { return ( <div className="flex items-center justify-center p-8"> <Loader2 className="w-5 h-5 animate-spin" /> </div> ); } // Обработчик правого клика для открытия контекстного меню const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY, }, }); }; // Закрытие контекстного меню const handleCloseContextMenu = () => { setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, }); }; // Обработчик двойного клика для создания нового блока в пустом пространстве const handleDoubleClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; // Проверяем, что клик был именно на кликабельной области под контентом if (target.classList.contains("editor-click-area")) { e.preventDefault(); // Добавляем новый параграф в конец документа и устанавливаем курсор editor .chain() .focus("end") .insertContentAt(editor.state.doc.content.size, { type: "paragraph" }) .focus("end") .run(); } }; return ( <div ref={editorElemRef} onContextMenu={handleContextMenu} onClick={handleCloseContextMenu} className="relative w-full h-full" > <BlockSelection editor={editor} containerRef={editorElemRef} selectedBlockIds={blockSelection.selectedBlockIds} setIsSelecting={blockSelection.setIsSelecting} addToSelection={blockSelection.addToSelection} removeFromSelection={blockSelection.removeFromSelection} setSelection={blockSelection.setSelection} > <div className="w-full relative flex flex-col justify-center" onDoubleClick={handleDoubleClick} > <div className="flex justify-center"> <EditorContent editor={editor} className="prose-editor max-w-3xl w-full py-0 px-16" /> </div> <div className="editor-click-area max-w-3xl w-full mx-auto px-16 min-h-[30vh] cursor-text" aria-hidden="true" /> <FloatingToolbar editor={editor} /> <DragHandle editor={editor}> <div className="flex items-center gap-1 translate-y-[2px] translate-x-[-8px]"> <Button variant="ghost" size="icon" className="size-5 rounded" onMouseDown={handlePlusClick} data-drag-handle-ignore > <Plus /> </Button> <Button variant="ghost" size="icon" className="size-5 rounded" onContextMenu={handleContextMenu} > <GripVertical /> </Button> </div> </DragHandle> <BlockContextMenu editor={editor} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={handleCloseContextMenu} selectedBlockIds={blockSelection.selectedBlockIds} deleteSelectedBlocks={blockSelection.deleteSelectedBlocks} /> <PlusMenuComponent items={plusActions} isOpen={plusMenuState.isOpen} position={plusMenuState.position} onSelect={handleSelectPlusAction} onClose={handleClosePlusMenu} /> </div> </BlockSelection> </div> ); }