Skip to content
Open
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
16 changes: 9 additions & 7 deletions packages/modules.editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@
"dev": "tsc --watch"
},
"dependencies": {
"@tanstack/react-router": "^1.166.6",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
"@floating-ui/core": "1.7.0",
"@floating-ui/react": "0.27.8",
"@hocuspocus/provider": "3.4.4",
"@tanstack/react-router": "^1.166.6",
"@tiptap/core": "^3.23.6",
"@tiptap/extension-code-block-lowlight": "^3.26.1",
"@tiptap/extension-collaboration": "^3.23.6",
"@tiptap/extension-collaboration-caret": "^3.23.6",
"@tiptap/extension-drag-handle": "^3.23.6",
"@tiptap/extension-drag-handle-react": "^3.23.6",
"@tiptap/extension-underline": "^3.23.6",
"@tiptap/extension-text-align": "^3.23.6",
"@tiptap/extension-image": "^3.23.6",
"@tiptap/extension-list": "^3.23.6",
"@tiptap/extension-text-align": "^3.23.6",
"@tiptap/extension-underline": "^3.23.6",
"@tiptap/extension-unique-id": "^3.23.6",
"@tiptap/extension-image": "^3.23.6",
"@tiptap/pm": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
Expand All @@ -44,17 +45,18 @@
"common.config": "workspace:*",
"common.env": "workspace:*",
"common.services": "workspace:*",
"common.ui": "workspace:*",
"common.types": "workspace:*",
"common.ui": "workspace:*",
"lowlight": "^3.3.0",
"nanoid": "5.1.5",
"prismjs": "^1.30.0",
"randomcolor": "0.6.2",
"sonner": "^2.0.7",
"webpfy": "1.0.9",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27",
"zustand": "^5.0.4",
"webpfy": "1.0.9"
"zustand": "^5.0.4"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/modules.editor/src/config/editorConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { UniqueID } from '@tiptap/extension-unique-id';
import * as Y from 'yjs';
import { CustomImage, MoveBlockKeyboard, NormalizeSelection } from '../extensions';
import { ExtraShortcuts } from '../extensions/extra-keyboard-shortcuts';
import 'highlight.js/styles/base16/atelier-cave-light.min.css';
import { CustomCodeNode } from '../extensions/code';

/** Курсор в стиле доски: вертикальная линия + шильдик с именем */
function collaborationCaretRender(user: { name?: string; color?: string }): HTMLElement {
Expand Down Expand Up @@ -65,6 +67,7 @@ export const getExtensions = (
width: 2,
color: '#3b82f6',
},
codeBlock: false,
}),
CustomImage,
Underline,
Expand Down Expand Up @@ -94,6 +97,7 @@ export const getExtensions = (
MoveBlockKeyboard,
NormalizeSelection,
ExtraShortcuts,
CustomCodeNode,
];

if (!provider || !ydoc) {
Expand Down
132 changes: 132 additions & 0 deletions packages/modules.editor/src/extensions/code/CodeBlockNodeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { NodeViewWrapper, NodeViewProps, NodeViewContent } from '@tiptap/react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@xipkg/dropdown';
import { Button } from '@xipkg/button';
import { MoreVert, Copy, Trash } from '@xipkg/icons';
import { useBlockMenuActions, useYjsContext } from '../../hooks';
import { cn } from '@xipkg/utils';
import { useCallback } from 'react';
import { ActiveBlockT } from '../../types';
import { common } from 'lowlight';
import { toast } from 'sonner';

export const CodeBlockNodeView = ({ node, getPos, updateAttributes }: NodeViewProps) => {
const currentLang = node.attrs.language || 'plaintext';

const { editor, isReadOnly } = useYjsContext();

// Функция точного определения текущего блока для хука
const getActiveBlock = useCallback((): ActiveBlockT | undefined => {
if (typeof getPos !== 'function' || !editor) return;
try {
const pos = getPos();
if (pos == null || pos < 0) return;
const { doc } = editor.state;
if (pos >= doc.content.size) return;

const $pos = doc.resolve(pos);
const nodeAtPos = $pos.nodeAfter;

if (nodeAtPos?.type.name === 'codeBlock') {
return { editor, node: nodeAtPos, pos };
}
} catch {
return;
}
}, [editor, getPos]);

const { remove } = useBlockMenuActions(editor, getActiveBlock);

// Копирование содержимого блока кода в буфер обмена
const handleCopyCode = () => {
const codeText = node.textContent;
navigator.clipboard.writeText(codeText);
toast.success('Код скопирован в буфер обмена');
};

// Изменение языка через встроенный метод Tiptap `updateAttributes`
const handleLanguageChange = (newLang: string) => {
updateAttributes({ language: newLang });
};

const availableLanguages = Object.keys(common);

return (
<NodeViewWrapper className="group relative">
<div
className={cn(
'pointer-events-none absolute top-2 right-2 z-10 flex items-center opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100',
)}
contentEditable={false}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="s" variant="none" className="text-gray-80" disabled={isReadOnly}>
{currentLang === 'plaintext' ? 'text' : currentLang}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="border-gray-10 flex max-h-[240px] w-[150px] flex-col space-y-1 overflow-y-auto rounded-xl border p-1 text-gray-100"
>
<DropdownMenuItem
className="hover:bg-gray-5 h-7 rounded px-2 text-xs"
onSelect={() => handleLanguageChange('plaintext')}
>
Plain text
</DropdownMenuItem>
{availableLanguages.map((lang) => (
<DropdownMenuItem
key={lang}
className="hover:bg-gray-5 h-7 rounded px-2 text-xs capitalize"
onSelect={() => handleLanguageChange(lang)}
>
{lang}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="s" variant="none" className="text-gray-0">
<MoreVert size="sm" className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="border-gray-10 flex w-[160px] flex-col space-y-1 rounded-xl border p-2"
>
<DropdownMenuItem
className="hover:bg-gray-5 h-7 gap-2 rounded p-1"
onSelect={handleCopyCode}
>
<Copy size="sm" className="size-4" />
<span className="text-xs">Копировать</span>
</DropdownMenuItem>

{!isReadOnly && (
<DropdownMenuItem
className="hover:bg-gray-5 h-7 gap-2 rounded p-1 text-red-500"
onSelect={remove}
>
<Trash size="sm" className="size-4 text-red-500" />
<span className="text-xs">Удалить блок</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

{/* Обязательный контейнер Tiptap для рендеринга редактируемого текста */}
<pre>
<NodeViewContent className="text-inherit" />
</pre>
</NodeViewWrapper>
);
};
16 changes: 16 additions & 0 deletions packages/modules.editor/src/extensions/code/CustomCodeNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ReactNodeViewRenderer } from '@tiptap/react';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { common, createLowlight } from 'lowlight';
import { CodeBlockNodeView } from './CodeBlockNodeView';

const lowlight = createLowlight(common);

export const CustomCodeNode = CodeBlockLowlight.configure({
lowlight,
enableTabIndentation: true,
defaultLanguage: 'plaintext',
}).extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockNodeView);
},
});
2 changes: 2 additions & 0 deletions packages/modules.editor/src/extensions/code/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { CodeBlockNodeView } from './CodeBlockNodeView.tsx';
export { CustomCodeNode } from './CustomCodeNode.tsx';
13 changes: 3 additions & 10 deletions packages/modules.editor/src/extensions/image/ImageNodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ import { Button } from '@xipkg/button';
import { ArrowBottom, ArrowUp, Copy, Download, MoreVert, Trash } from '@xipkg/icons';
import { useBlockMenuActions, useProtectedImage, useYjsContext } from '../../hooks';
import { cn } from '@xipkg/utils';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { ActiveBlockT } from '../../types';
import { NodeSelection } from '@tiptap/pm/state';

export const ImageNodeView = ({ node, getPos }: NodeViewProps) => {
const [hovered, setHovered] = useState(false);
const src = node.attrs.src;

const { editor, storageToken, isReadOnly } = useYjsContext();
Expand Down Expand Up @@ -60,12 +59,7 @@ export const ImageNodeView = ({ node, getPos }: NodeViewProps) => {
const imageSrc = useProtectedImage(src, storageToken);

return (
<NodeViewWrapper
className="group relative flex justify-center"
contentEditable={false}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<NodeViewWrapper className="group relative flex justify-center" contentEditable={false}>
<img
src={imageSrc}
alt={node.attrs.alt || ''}
Expand All @@ -77,8 +71,7 @@ export const ImageNodeView = ({ node, getPos }: NodeViewProps) => {
/>
<div
className={cn(
'absolute top-2 right-2 flex transition-opacity',
hovered ? 'pointer-events-auto opacity-100' : 'opacity-0',
'absolute top-2 right-2 flex opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<DropdownMenu modal={false}>
Expand Down
18 changes: 18 additions & 0 deletions packages/modules.editor/src/hooks/useBlockMenuActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ export const useBlockMenuActions = (
return createBlock(editor, type, activeBlock);
};

const insertCode = (codeText: string = '', language: string = 'plaintext') => {
if (!editor || !editor.isEditable) return;

const endPos = editor.state.doc.content.size;
editor
.chain()
.focus()
.insertContentAt(endPos, {
type: 'codeBlock',
attrs: { language: language || 'plaintext' },
content: codeText ? [{ type: 'text', text: codeText }] : [],
})
.run();
return;
};

// В момент вызова получаем свежую позицию
const moveUp = () => {
if (!getActiveBlock) return;
const activeBlock = getActiveBlock();
Expand Down Expand Up @@ -144,6 +161,7 @@ export const useBlockMenuActions = (
downloadImage,
moveDown,
moveUp,
insertCode,
insertBlock,
};
};
Expand Down
10 changes: 7 additions & 3 deletions packages/modules.editor/src/ui/components/BlockMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@xipkg/dropdown';
import { Copy, H1, H2, H3, Text, Trash, Image, ArrowUp, ArrowBottom } from '@xipkg/icons';
import { Copy, H1, H2, H3, Text, Trash, Image, ArrowUp, ArrowBottom, Code } from '@xipkg/icons';
import { ReactNode } from 'react';
import { useBlockMenuActions } from '../../hooks';
import { Editor } from '@tiptap/core';
Expand Down Expand Up @@ -43,7 +43,7 @@ export const BlockMenu = ({
}: BlockMenuPropsT) => {
const isMac = navigator.platform.toUpperCase().includes('MAC');
const { openModal } = useInterfaceStore();
const { insertBlock, duplicate, remove, moveUp, moveDown } = useBlockMenuActions(
const { insertBlock, duplicate, remove, moveUp, moveDown, insertCode } = useBlockMenuActions(
editor,
getActiveBlock,
);
Expand All @@ -56,7 +56,7 @@ export const BlockMenu = ({
}

return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>

<DropdownMenuContent
Expand Down Expand Up @@ -89,6 +89,10 @@ export const BlockMenu = ({
<Image size="sm" className="size-6" />
<span>Изображение</span>
</DropdownMenuItem>
<DropdownMenuItem className={menuItemClass} onSelect={() => insertCode('')}>
<Code size="sm" className="size-6" />
<span>Вставить код</span>
</DropdownMenuItem>

<DropdownMenuSeparator />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const DragHandleWrapper = ({
nested
onNodeChange={handleNodeChange}
>
<div className="mr-1 flex items-center gap-2">
<div className="pointer-events-auto mr-1 flex items-center gap-2">
<BlockMenu
editor={editor}
isReadOnly={isReadOnly}
Expand Down
28 changes: 12 additions & 16 deletions packages/modules.editor/src/ui/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,7 @@

/* Стили для кода */
.xi-editor .ProseMirror pre {
background: var(--xi-gray-90);
color: var(--xi-gray-0);
background: var(--xi-gray-0);
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
Expand All @@ -125,20 +124,6 @@
color: var(--xi-gray-100);
}

.xi-editor .ProseMirror code {
background: var(--xi-gray-10);
color: var(--xi-gray-100);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}

.xi-editor .ProseMirror pre code {
background: transparent;
padding: 0;
color: inherit;
}

/* Стили для ссылок */
.xi-editor .ProseMirror a {
color: var(--xi-brand-80);
Expand Down Expand Up @@ -254,6 +239,17 @@
border-radius: 2px;
}

/* Стилизация блока кода */
.xi-editor .tiptap pre {
font-family:
'JetBrains Mono',
Courier New,
Courier,
monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
}

/* Стили для мобильных устройств */
@media (max-width: 768px) {
.xi-editor .ProseMirror h1 {
Expand Down
Loading
Loading