mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 13:22:38 +10:00
work on editor
This commit is contained in:
@ -15,5 +15,7 @@ module.exports = {
|
|||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,24 +14,34 @@
|
|||||||
"@mantine/form": "^7.1.5",
|
"@mantine/form": "^7.1.5",
|
||||||
"@mantine/hooks": "^7.1.5",
|
"@mantine/hooks": "^7.1.5",
|
||||||
"@mantine/spotlight": "^7.1.5",
|
"@mantine/spotlight": "^7.1.5",
|
||||||
"@mantine/tiptap": "^7.1.5",
|
|
||||||
"@tabler/icons-react": "^2.39.0",
|
"@tabler/icons-react": "^2.39.0",
|
||||||
"@tanstack/react-query": "^4.36.1",
|
"@tanstack/react-query": "^4.36.1",
|
||||||
"@tanstack/react-table": "^8.10.7",
|
"@tanstack/react-table": "^8.10.7",
|
||||||
|
"@tiptap/extension-code-block": "^2.1.12",
|
||||||
"@tiptap/extension-collaboration": "^2.1.12",
|
"@tiptap/extension-collaboration": "^2.1.12",
|
||||||
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
||||||
|
"@tiptap/extension-color": "^2.1.12",
|
||||||
"@tiptap/extension-document": "^2.1.12",
|
"@tiptap/extension-document": "^2.1.12",
|
||||||
"@tiptap/extension-heading": "^2.1.12",
|
"@tiptap/extension-heading": "^2.1.12",
|
||||||
"@tiptap/extension-highlight": "^2.1.12",
|
"@tiptap/extension-highlight": "^2.1.12",
|
||||||
"@tiptap/extension-link": "^2.1.12",
|
"@tiptap/extension-link": "^2.1.12",
|
||||||
|
"@tiptap/extension-list-item": "^2.1.12",
|
||||||
|
"@tiptap/extension-list-keymap": "^2.1.12",
|
||||||
|
"@tiptap/extension-mention": "^2.1.12",
|
||||||
"@tiptap/extension-placeholder": "^2.1.12",
|
"@tiptap/extension-placeholder": "^2.1.12",
|
||||||
"@tiptap/extension-subscript": "^2.1.12",
|
"@tiptap/extension-subscript": "^2.1.12",
|
||||||
"@tiptap/extension-superscript": "^2.1.12",
|
"@tiptap/extension-superscript": "^2.1.12",
|
||||||
|
"@tiptap/extension-task-item": "^2.1.12",
|
||||||
|
"@tiptap/extension-task-list": "^2.1.12",
|
||||||
|
"@tiptap/extension-text": "^2.1.12",
|
||||||
"@tiptap/extension-text-align": "^2.1.12",
|
"@tiptap/extension-text-align": "^2.1.12",
|
||||||
|
"@tiptap/extension-text-style": "^2.1.12",
|
||||||
|
"@tiptap/extension-typography": "^2.1.12",
|
||||||
"@tiptap/extension-underline": "^2.1.12",
|
"@tiptap/extension-underline": "^2.1.12",
|
||||||
"@tiptap/pm": "^2.1.12",
|
"@tiptap/pm": "^2.1.12",
|
||||||
"@tiptap/react": "^2.1.12",
|
"@tiptap/react": "^2.1.12",
|
||||||
"@tiptap/starter-kit": "^2.1.12",
|
"@tiptap/starter-kit": "^2.1.12",
|
||||||
|
"@tiptap/suggestion": "^2.1.12",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"jotai": "^2.4.3",
|
"jotai": "^2.4.3",
|
||||||
@ -43,6 +53,7 @@
|
|||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-router-dom": "^6.17.0",
|
"react-router-dom": "^6.17.0",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"y-indexeddb": "^9.0.11",
|
"y-indexeddb": "^9.0.11",
|
||||||
"yjs": "^13.6.8",
|
"yjs": "^13.6.8",
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
breakpoint: 'sm',
|
breakpoint: 'sm',
|
||||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||||
}}
|
}}
|
||||||
|
aside={{ width: 300, breakpoint: 'md', collapsed: { desktop: false, mobile: true } }}
|
||||||
padding="md"
|
padding="md"
|
||||||
>
|
>
|
||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
@ -45,7 +46,14 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
<AppShell.Main>{children}</AppShell.Main>
|
|
||||||
|
<AppShell.Main>
|
||||||
|
{children}
|
||||||
|
</AppShell.Main>
|
||||||
|
|
||||||
|
<AppShell.Aside>
|
||||||
|
TODO
|
||||||
|
</AppShell.Aside>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
.bubbleMenu {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: var(--mantine-color-blue-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorButton {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorButton::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from '@tiptap/react';
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline } from '@tabler/icons-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import classes from './bubble-menu.module.css';
|
||||||
|
import { ActionIcon, rem, Tooltip } from '@mantine/core';
|
||||||
|
import { ColorSelector } from './color-selector';
|
||||||
|
import { NodeSelector } from './node-selector';
|
||||||
|
|
||||||
|
export interface BubbleMenuItem {
|
||||||
|
name: string;
|
||||||
|
isActive: () => boolean;
|
||||||
|
command: () => void;
|
||||||
|
icon: typeof IconBold;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
|
||||||
|
|
||||||
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
|
const items: BubbleMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: 'bold',
|
||||||
|
isActive: () => props.editor.isActive('bold'),
|
||||||
|
command: () => props.editor.chain().focus().toggleBold().run(),
|
||||||
|
icon: IconBold,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'italic',
|
||||||
|
isActive: () => props.editor.isActive('italic'),
|
||||||
|
command: () => props.editor.chain().focus().toggleItalic().run(),
|
||||||
|
icon: IconItalic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'underline',
|
||||||
|
isActive: () => props.editor.isActive('underline'),
|
||||||
|
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
||||||
|
icon: IconUnderline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'strike',
|
||||||
|
isActive: () => props.editor.isActive('strike'),
|
||||||
|
command: () => props.editor.chain().focus().toggleStrike().run(),
|
||||||
|
icon: IconStrikethrough,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
isActive: () => props.editor.isActive('code'),
|
||||||
|
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||||
|
icon: IconCode,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
|
...props,
|
||||||
|
shouldShow: ({ state, editor }) => {
|
||||||
|
const { selection } = state;
|
||||||
|
const { empty } = selection;
|
||||||
|
|
||||||
|
if (editor.isActive('image') || empty || isNodeSelection(selection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
tippyOptions: {
|
||||||
|
moveTransition: 'transform 0.15s ease-out',
|
||||||
|
onHidden: () => {
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
{...bubbleMenuProps}
|
||||||
|
className={classes.bubbleMenu}
|
||||||
|
>
|
||||||
|
<NodeSelector
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isNodeSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionIcon.Group>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Tooltip key={index} label={item.name}>
|
||||||
|
|
||||||
|
<ActionIcon key={index} variant="default" size="lg" radius="0" aria-label={item.name}
|
||||||
|
className={clsx({ [classes.active]: item.isActive() })}
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
onClick={item.command}>
|
||||||
|
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
))}
|
||||||
|
</ActionIcon.Group>
|
||||||
|
|
||||||
|
<ColorSelector
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isColorSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsColorSelectorOpen(!isColorSelectorOpen);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</BubbleMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { Dispatch, FC, SetStateAction } from 'react';
|
||||||
|
import { IconCheck, IconChevronDown } from '@tabler/icons-react';
|
||||||
|
import { Button, Popover, rem, ScrollArea, Text } from '@mantine/core';
|
||||||
|
import classes from './bubble-menu.module.css';
|
||||||
|
|
||||||
|
export interface BubbleColorMenuItem {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorSelectorProps {
|
||||||
|
editor: Editor;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_COLORS: BubbleColorMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Default',
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Blue',
|
||||||
|
color: '#2563EB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Green',
|
||||||
|
color: '#008A00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Purple',
|
||||||
|
color: '#9333EA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Red',
|
||||||
|
color: '#E00000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yellow',
|
||||||
|
color: '#EAB308',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orange',
|
||||||
|
color: '#FFA500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pink',
|
||||||
|
color: '#BA4081',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gray',
|
||||||
|
color: '#A8A29E',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// TODO: handle dark mode
|
||||||
|
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Default',
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Blue',
|
||||||
|
color: '#c1ecf9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Green',
|
||||||
|
color: '#acf79f',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Purple',
|
||||||
|
color: '#f6f3f8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Red',
|
||||||
|
color: '#fdebeb',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yellow',
|
||||||
|
color: '#fbf4a2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orange',
|
||||||
|
color: '#faebdd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pink',
|
||||||
|
color: '#faf1f5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gray',
|
||||||
|
color: '#f1f1ef',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ColorSelector: FC<ColorSelectorProps> =
|
||||||
|
({ editor, isOpen, setIsOpen }) => {
|
||||||
|
|
||||||
|
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||||
|
editor.isActive('textStyle', { color }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||||
|
editor.isActive('highlight', { color }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover width={200} opened={isOpen} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
|
||||||
|
<Button variant="default" radius="0"
|
||||||
|
leftSection="A"
|
||||||
|
rightSection={<IconChevronDown size={16} />}
|
||||||
|
className={classes.colorButton}
|
||||||
|
style={{
|
||||||
|
color: activeColorItem?.color,
|
||||||
|
}}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
/>
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown>
|
||||||
|
{/* make mah responsive */}
|
||||||
|
<ScrollArea.Autosize type="scroll" mah='400'>
|
||||||
|
|
||||||
|
<Text span c="dimmed" inherit>COLOR</Text>
|
||||||
|
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
{TEXT_COLORS.map(({ name, color }, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<span style={{ color }}>A</span>}
|
||||||
|
justify="left"
|
||||||
|
fullWidth
|
||||||
|
rightSection={editor.isActive('textStyle', { color })
|
||||||
|
&& (<IconCheck style={{ width: rem(16) }} />)}
|
||||||
|
|
||||||
|
onClick={() => {
|
||||||
|
editor.commands.unsetColor();
|
||||||
|
name !== 'Default' &&
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setColor(color || '')
|
||||||
|
.run();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Button.Group>
|
||||||
|
|
||||||
|
<Text span c="dimmed" inherit>BACKGROUND</Text>
|
||||||
|
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<span style={{ padding: '4px', background: color }}>A</span>}
|
||||||
|
justify="left"
|
||||||
|
fullWidth
|
||||||
|
rightSection={editor.isActive('highlight', { color })
|
||||||
|
&& (<IconCheck style={{ width: rem(16) }} />)}
|
||||||
|
|
||||||
|
onClick={() => {
|
||||||
|
editor.commands.unsetHighlight();
|
||||||
|
name !== 'Default' &&
|
||||||
|
editor
|
||||||
|
.commands
|
||||||
|
.setHighlight({ color });
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Button.Group>
|
||||||
|
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { Dispatch, FC, SetStateAction } from 'react';
|
||||||
|
import {
|
||||||
|
IconBlockquote,
|
||||||
|
IconCheck, IconCheckbox, IconChevronDown, IconCode,
|
||||||
|
IconH1,
|
||||||
|
IconH2,
|
||||||
|
IconH3,
|
||||||
|
IconList,
|
||||||
|
IconListNumbers,
|
||||||
|
IconTypography,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { Popover, Button, rem, ScrollArea } from '@mantine/core';
|
||||||
|
import classes from '@/features/editor/components/bubble-menu/bubble-menu.module.css';
|
||||||
|
|
||||||
|
interface NodeSelectorProps {
|
||||||
|
editor: Editor;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BubbleMenuItem {
|
||||||
|
name: string;
|
||||||
|
icon: FC;
|
||||||
|
command: () => void;
|
||||||
|
isActive: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeSelector: FC<NodeSelectorProps> =
|
||||||
|
({ editor, isOpen, setIsOpen }) => {
|
||||||
|
|
||||||
|
const items: BubbleMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Text',
|
||||||
|
icon: IconTypography,
|
||||||
|
command: () =>
|
||||||
|
editor.chain().focus().toggleNode('paragraph', 'paragraph').run(),
|
||||||
|
isActive: () =>
|
||||||
|
editor.isActive('paragraph') &&
|
||||||
|
!editor.isActive('bulletList') &&
|
||||||
|
!editor.isActive('orderedList'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Heading 1',
|
||||||
|
icon: IconH1,
|
||||||
|
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||||
|
isActive: () => editor.isActive('heading', { level: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Heading 2',
|
||||||
|
icon: IconH2,
|
||||||
|
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||||
|
isActive: () => editor.isActive('heading', { level: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Heading 3',
|
||||||
|
icon: IconH3,
|
||||||
|
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||||
|
isActive: () => editor.isActive('heading', { level: 3 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'To-do List',
|
||||||
|
icon: IconCheckbox,
|
||||||
|
command: () => editor.chain().focus().toggleTaskList().run(),
|
||||||
|
isActive: () => editor.isActive('taskItem'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bullet List',
|
||||||
|
icon: IconList,
|
||||||
|
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
isActive: () => editor.isActive('bulletList'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Numbered List',
|
||||||
|
icon: IconListNumbers,
|
||||||
|
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||||
|
isActive: () => editor.isActive('orderedList'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Blockquote',
|
||||||
|
icon: IconBlockquote,
|
||||||
|
command: () =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleNode('paragraph', 'paragraph')
|
||||||
|
.toggleBlockquote()
|
||||||
|
.run(),
|
||||||
|
isActive: () => editor.isActive('blockquote'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Code',
|
||||||
|
icon: IconCode,
|
||||||
|
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
|
isActive: () => editor.isActive('codeBlock'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
|
name: 'Multiple',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover opened={isOpen} withArrow>
|
||||||
|
|
||||||
|
<Popover.Target>
|
||||||
|
<Button variant="default" radius="0"
|
||||||
|
rightSection={<IconChevronDown size={16} />}
|
||||||
|
className={classes.colorButton}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{activeItem?.name}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<ScrollArea.Autosize type="scroll" mah={400}>
|
||||||
|
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
|
||||||
|
{items.map((item, index) => (
|
||||||
|
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<item.icon size={16} />}
|
||||||
|
rightSection={activeItem.name === item.name
|
||||||
|
&& (<IconCheck style={{ width: rem(16) }} />)}
|
||||||
|
justify="left"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => {
|
||||||
|
item.command();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</Button.Group>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
SlashMenuItemType,
|
||||||
|
} from '@/features/editor/components/slash-menu/types';
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import classes from './slash-menu.module.css';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
const CommandList = ({
|
||||||
|
items,
|
||||||
|
command,
|
||||||
|
editor,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
items: SlashMenuItemType[];
|
||||||
|
command: any;
|
||||||
|
editor: any;
|
||||||
|
range: any;
|
||||||
|
}) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const flatItems = useMemo(() => {
|
||||||
|
return Object.values(items).flat();
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = flatItems[index];
|
||||||
|
if (item) {
|
||||||
|
command(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[command, flatItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const navigationKeys = ['ArrowUp', 'ArrowDown', 'Enter'];
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (navigationKeys.includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
setSelectedIndex((selectedIndex + flatItems.length - 1) % flatItems.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % flatItems.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [flatItems, selectedIndex, setSelectedIndex, selectItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [flatItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewportRef.current
|
||||||
|
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||||
|
?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
return flatItems.length > 0 ? (
|
||||||
|
<Paper id="slash-command" shadow="xl" p="sm" withBorder>
|
||||||
|
<ScrollArea viewportRef={viewportRef} h={350} w={250} scrollbarSize={5}>
|
||||||
|
{Object.entries(items).map(([category, categoryItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||||
|
{category}
|
||||||
|
</Text>
|
||||||
|
{categoryItems.map((item: SlashMenuItemType, index: number) => (
|
||||||
|
<UnstyledButton
|
||||||
|
data-item-index={index}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
className={clsx(classes.menuBtn, { [classes.selectedItem]: index === selectedIndex })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--mantine-spacing-xs)',
|
||||||
|
color: 'var(--mantine-color-text)',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<item.icon size={18} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</Paper>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommandList;
|
||||||
163
client/src/features/editor/components/slash-menu/menu-items.ts
Normal file
163
client/src/features/editor/components/slash-menu/menu-items.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
IconBlockquote,
|
||||||
|
IconCheckbox, IconCode,
|
||||||
|
IconH1,
|
||||||
|
IconH2,
|
||||||
|
IconH3,
|
||||||
|
IconList,
|
||||||
|
IconListNumbers, IconPhoto,
|
||||||
|
IconTypography,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { CommandProps, SlashMenuGroupedItemsType } from '@/features/editor/components/slash-menu/types';
|
||||||
|
|
||||||
|
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||||
|
basic: [
|
||||||
|
{
|
||||||
|
title: 'Text',
|
||||||
|
description: 'Just start typing with plain text.',
|
||||||
|
searchTerms: ['p', 'paragraph'],
|
||||||
|
icon: IconTypography,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode('paragraph', 'paragraph')
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'To-do List',
|
||||||
|
description: 'Track tasks with a to-do list.',
|
||||||
|
searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'],
|
||||||
|
icon: IconCheckbox,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Heading 1',
|
||||||
|
description: 'Big section heading.',
|
||||||
|
searchTerms: ['title', 'big', 'large'],
|
||||||
|
icon: IconH1,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 1 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Heading 2',
|
||||||
|
description: 'Medium section heading.',
|
||||||
|
searchTerms: ['subtitle', 'medium'],
|
||||||
|
icon: IconH2,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 2 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Heading 3',
|
||||||
|
description: 'Small section heading.',
|
||||||
|
searchTerms: ['subtitle', 'small'],
|
||||||
|
icon: IconH3,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 3 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bullet List',
|
||||||
|
description: 'Create a simple bullet list.',
|
||||||
|
searchTerms: ['unordered', 'point'],
|
||||||
|
icon: IconList,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Numbered List',
|
||||||
|
description: 'Create a list with numbering.',
|
||||||
|
searchTerms: ['ordered'],
|
||||||
|
icon: IconListNumbers,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Quote',
|
||||||
|
description: 'Capture a quote.',
|
||||||
|
searchTerms: ['blockquote', 'quotes'],
|
||||||
|
icon: IconBlockquote,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode('paragraph', 'paragraph')
|
||||||
|
.toggleBlockquote()
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Code',
|
||||||
|
description: 'Capture a code snippet.',
|
||||||
|
searchTerms: ['codeblock'],
|
||||||
|
icon: IconCode,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Image',
|
||||||
|
description: 'Upload an image from your computer.',
|
||||||
|
searchTerms: ['photo', 'picture', 'media'],
|
||||||
|
icon: IconPhoto,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
// upload image
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const pos = editor.view.state.selection.from;
|
||||||
|
//startImageUpload(file, editor.view, pos);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSuggestionItems = ({ query }: { query: string }): SlashMenuGroupedItemsType => {
|
||||||
|
const search = query.toLowerCase();
|
||||||
|
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||||
|
|
||||||
|
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||||
|
const filteredItems = items.filter((item) => {
|
||||||
|
return item.title.toLowerCase().includes(search)
|
||||||
|
|| item.description.toLowerCase().includes(search)
|
||||||
|
|| (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredItems.length) {
|
||||||
|
filteredGroups[group] = filteredItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getSuggestionItems;
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { ReactRenderer } from '@tiptap/react';
|
||||||
|
import CommandList from '@/features/editor/components/slash-menu/command-list';
|
||||||
|
import tippy from 'tippy.js';
|
||||||
|
|
||||||
|
const renderItems = () => {
|
||||||
|
let component: ReactRenderer | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
component = new ReactRenderer(CommandList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
component?.updateProps(props);
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup?.[0].hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return component?.ref?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
if (popup && !popup[0].state.isDestroyed) {
|
||||||
|
popup[0].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default renderItems;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
.menuBtn {
|
||||||
|
&:hover {
|
||||||
|
@mixin light {
|
||||||
|
background: var(--mantine-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: var(--mantine-color-gray-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedItem {
|
||||||
|
@mixin light {
|
||||||
|
background: var(--mantine-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: var(--mantine-color-gray-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
client/src/features/editor/components/slash-menu/types.ts
Normal file
25
client/src/features/editor/components/slash-menu/types.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Editor, Range } from '@tiptap/core';
|
||||||
|
|
||||||
|
export type CommandProps = {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandListProps = {
|
||||||
|
items: SlashMenuGroupedItemsType;
|
||||||
|
command: (item: SlashMenuItemType) => void;
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlashMenuItemType = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: any;
|
||||||
|
separator?: true;
|
||||||
|
searchTerms: string[];
|
||||||
|
command: (props: CommandProps) => void;
|
||||||
|
disable?: (editor: Editor) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlashMenuGroupedItemsType = Record<string, SlashMenuItemType[]>;
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import '@/features/editor/styles/editor.css';
|
|
||||||
|
|
||||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
@ -7,23 +5,35 @@ import { StarterKit } from '@tiptap/starter-kit';
|
|||||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||||
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
||||||
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
||||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||||
import { RichTextEditor } from '@mantine/tiptap';
|
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { TextAlign } from '@tiptap/extension-text-align';
|
||||||
import { Highlight } from '@tiptap/extension-highlight';
|
import { Highlight } from '@tiptap/extension-highlight';
|
||||||
import { Superscript } from '@tiptap/extension-superscript';
|
import { Superscript } from '@tiptap/extension-superscript';
|
||||||
import SubScript from '@tiptap/extension-subscript';
|
import SubScript from '@tiptap/extension-subscript';
|
||||||
import { Link } from '@tiptap/extension-link';
|
import { Link } from '@tiptap/extension-link';
|
||||||
import { Underline } from '@tiptap/extension-underline';
|
import { Underline } from '@tiptap/extension-underline';
|
||||||
|
import { Typography } from '@tiptap/extension-typography';
|
||||||
|
import { TaskItem } from '@tiptap/extension-task-item';
|
||||||
|
import { TaskList } from '@tiptap/extension-task-list';
|
||||||
|
import classes from '@/features/editor/styles/editor.module.css';
|
||||||
|
import '@/features/editor/styles/index.css';
|
||||||
|
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||||
|
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||||
|
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
|
||||||
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
|
import { Color } from '@tiptap/extension-color';
|
||||||
|
import SlashCommand from '@/features/editor/extensions/slash-command';
|
||||||
|
import { Document } from '@tiptap/extension-document';
|
||||||
|
import { Text } from '@tiptap/extension-text';
|
||||||
|
import { Heading } from '@tiptap/extension-heading';
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
pageId: string,
|
pageId: string,
|
||||||
token?: string,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D'];
|
const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D'];
|
||||||
@ -35,11 +45,13 @@ export default function Editor({ pageId }: EditorProps) {
|
|||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const [provider, setProvider] = useState<any>();
|
const [provider, setProvider] = useState<any>();
|
||||||
const [yDoc] = useState(() => new Y.Doc());
|
const [yDoc] = useState(() => new Y.Doc());
|
||||||
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
const indexeddbProvider = new IndexeddbPersistence(pageId, yDoc)
|
const indexeddbProvider = new IndexeddbPersistence(pageId, yDoc);
|
||||||
|
|
||||||
const provider = new HocuspocusProvider({
|
const provider = new HocuspocusProvider({
|
||||||
url: collaborationURL,
|
url: collaborationURL,
|
||||||
name: pageId,
|
name: pageId,
|
||||||
@ -47,12 +59,23 @@ export default function Editor({ pageId }: EditorProps) {
|
|||||||
token: token.accessToken,
|
token: token.accessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
setProvider(provider);
|
indexeddbProvider.on('synced', () => {
|
||||||
|
console.log('index synced');
|
||||||
|
setLocalSynced(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.on('synced', () => {
|
||||||
|
console.log('remote synced');
|
||||||
|
setRemoteSynced(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
setProvider(provider);
|
||||||
return () => {
|
return () => {
|
||||||
provider.destroy();
|
|
||||||
setProvider(null);
|
setProvider(null);
|
||||||
|
provider.destroy();
|
||||||
indexeddbProvider.destroy();
|
indexeddbProvider.destroy();
|
||||||
|
setRemoteSynced(false);
|
||||||
|
setLocalSynced(false);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [pageId, token]);
|
}, [pageId, token]);
|
||||||
@ -61,9 +84,8 @@ export default function Editor({ pageId }: EditorProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const isSynced = isLocalSynced || isRemoteSynced;
|
||||||
<TiptapEditor ydoc={yDoc} provider={provider} />
|
return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} />);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TiptapEditorProps {
|
interface TiptapEditorProps {
|
||||||
@ -77,9 +99,10 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) {
|
|||||||
const extensions = [
|
const extensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
history: false,
|
history: false,
|
||||||
}),
|
dropcursor: {
|
||||||
Placeholder.configure({
|
width: 3,
|
||||||
placeholder: 'Write here',
|
color: '#70CFF8',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: ydoc,
|
document: ydoc,
|
||||||
@ -87,16 +110,80 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) {
|
|||||||
CollaborationCursor.configure({
|
CollaborationCursor.configure({
|
||||||
provider,
|
provider,
|
||||||
}),
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: 'Enter "/" for commands',
|
||||||
|
}),
|
||||||
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link,
|
Link,
|
||||||
Superscript,
|
Superscript,
|
||||||
SubScript,
|
SubScript,
|
||||||
Highlight,
|
Highlight.configure({
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
multicolor: true,
|
||||||
|
}),
|
||||||
|
Typography,
|
||||||
|
TrailingNode,
|
||||||
|
DragAndDrop,
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
SlashCommand,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const titleEditor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
Document.extend({
|
||||||
|
content: 'heading',
|
||||||
|
}),
|
||||||
|
Heading.configure({
|
||||||
|
levels: [1],
|
||||||
|
}),
|
||||||
|
Text,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: 'Untitled',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: there must be a better way
|
||||||
|
setTimeout(() => {
|
||||||
|
titleEditor?.commands.focus('start');
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, 50);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
|
autofocus: false,
|
||||||
|
editorProps: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (_view, event) => {
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
|
||||||
|
const slashCommand = document.querySelector('#slash-command');
|
||||||
|
if (slashCommand) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate({ editor }) {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
if (!selection.empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportCoords = editor.view.coordsAtPos(selection.from);
|
||||||
|
const absoluteOffset = window.scrollY + viewportCoords.top;
|
||||||
|
window.scrollTo(
|
||||||
|
window.scrollX,
|
||||||
|
absoluteOffset - (window.innerHeight / 2),
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -105,57 +192,29 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [editor, currentUser.user]);
|
}, [editor, currentUser.user]);
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTitleKeyDown(event) {
|
||||||
provider.on('status', event => {
|
if (!titleEditor || !editor || event.shiftKey) return;
|
||||||
console.log(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
}, [provider]);
|
const { key } = event;
|
||||||
|
const { $head } = titleEditor.state.selection;
|
||||||
|
|
||||||
|
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
|
||||||
|
(key === 'ArrowRight' && !$head.nodeAfter);
|
||||||
|
|
||||||
|
if (shouldFocusEditor) {
|
||||||
|
editor.commands.focus('start');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RichTextEditor editor={editor}>
|
<>
|
||||||
<RichTextEditor.Toolbar sticky stickyOffset={60}>
|
<div className={classes.editor}>
|
||||||
<RichTextEditor.ControlsGroup>
|
{editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<RichTextEditor.Bold />
|
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown}
|
||||||
<RichTextEditor.Italic />
|
/>
|
||||||
<RichTextEditor.Underline />
|
<EditorContent editor={editor} />
|
||||||
<RichTextEditor.Strikethrough />
|
</div>
|
||||||
<RichTextEditor.ClearFormatting />
|
</>
|
||||||
<RichTextEditor.Highlight />
|
|
||||||
<RichTextEditor.Code />
|
|
||||||
</RichTextEditor.ControlsGroup>
|
|
||||||
|
|
||||||
<RichTextEditor.ControlsGroup>
|
|
||||||
<RichTextEditor.H1 />
|
|
||||||
<RichTextEditor.H2 />
|
|
||||||
<RichTextEditor.H3 />
|
|
||||||
<RichTextEditor.H4 />
|
|
||||||
</RichTextEditor.ControlsGroup>
|
|
||||||
|
|
||||||
<RichTextEditor.ControlsGroup>
|
|
||||||
<RichTextEditor.Blockquote />
|
|
||||||
<RichTextEditor.Hr />
|
|
||||||
<RichTextEditor.BulletList />
|
|
||||||
<RichTextEditor.OrderedList />
|
|
||||||
<RichTextEditor.Subscript />
|
|
||||||
<RichTextEditor.Superscript />
|
|
||||||
</RichTextEditor.ControlsGroup>
|
|
||||||
|
|
||||||
<RichTextEditor.ControlsGroup>
|
|
||||||
<RichTextEditor.Link />
|
|
||||||
<RichTextEditor.Unlink />
|
|
||||||
</RichTextEditor.ControlsGroup>
|
|
||||||
|
|
||||||
<RichTextEditor.ControlsGroup>
|
|
||||||
<RichTextEditor.AlignLeft />
|
|
||||||
<RichTextEditor.AlignCenter />
|
|
||||||
<RichTextEditor.AlignJustify />
|
|
||||||
<RichTextEditor.AlignRight />
|
|
||||||
</RichTextEditor.ControlsGroup>
|
|
||||||
</RichTextEditor.Toolbar>
|
|
||||||
|
|
||||||
<RichTextEditor.Content />
|
|
||||||
</RichTextEditor>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
223
client/src/features/editor/extensions/drag-handle.ts
Normal file
223
client/src/features/editor/extensions/drag-handle.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { NodeSelection, Plugin } from '@tiptap/pm/state';
|
||||||
|
// @ts-ignore
|
||||||
|
import { __serializeForClipboard as serializeForClipboard, EditorView } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
export interface DragHandleOptions {
|
||||||
|
dragHandleWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(node) {
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteRect(node) {
|
||||||
|
const data = node.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: data.top,
|
||||||
|
left: data.left,
|
||||||
|
width: data.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||||
|
return document
|
||||||
|
.elementsFromPoint(coords.x, coords.y)
|
||||||
|
.find(
|
||||||
|
(elem: HTMLElement) =>
|
||||||
|
elem.parentElement?.matches?.('.ProseMirror') ||
|
||||||
|
elem.matches(
|
||||||
|
[
|
||||||
|
'li',
|
||||||
|
'p:not(:first-child)',
|
||||||
|
'pre',
|
||||||
|
'blockquote',
|
||||||
|
'h1, h2, h3',
|
||||||
|
'[data-type=horizontalRule]',
|
||||||
|
].join(', '),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodePosAtDOM(node: Element, view: EditorView) {
|
||||||
|
const boundingRect = node.getBoundingClientRect();
|
||||||
|
|
||||||
|
return view.posAtCoords({
|
||||||
|
left: boundingRect.left + 1,
|
||||||
|
top: boundingRect.top + 1,
|
||||||
|
})?.inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DragHandle(options: DragHandleOptions) {
|
||||||
|
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||||
|
view.focus();
|
||||||
|
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
|
||||||
|
const node = nodeDOMAtCoords({
|
||||||
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
|
||||||
|
const nodePos = nodePosAtDOM(node, view);
|
||||||
|
if (!nodePos) return;
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setSelection(
|
||||||
|
NodeSelection.create(view.state.doc, nodePos),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const slice = view.state.selection.content();
|
||||||
|
const { dom, text } = serializeForClipboard(view, slice);
|
||||||
|
|
||||||
|
event.dataTransfer.clearData();
|
||||||
|
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||||
|
event.dataTransfer.setData('text/plain', text);
|
||||||
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
|
||||||
|
event.dataTransfer.setDragImage(node, 0, 0);
|
||||||
|
|
||||||
|
view.dragging = { slice, move: event.ctrlKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent, view: EditorView) {
|
||||||
|
view.focus();
|
||||||
|
view.dom.classList.remove('dragging');
|
||||||
|
|
||||||
|
const node = nodeDOMAtCoords({
|
||||||
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
|
||||||
|
const nodePos = nodePosAtDOM(node, view);
|
||||||
|
if (!nodePos) return;
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setSelection(
|
||||||
|
NodeSelection.create(view.state.doc, nodePos),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragHandleElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function hideDragHandle() {
|
||||||
|
if (dragHandleElement) {
|
||||||
|
dragHandleElement.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDragHandle() {
|
||||||
|
if (dragHandleElement) {
|
||||||
|
dragHandleElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return new Plugin({
|
||||||
|
view: (view) => {
|
||||||
|
dragHandleElement = document.createElement('div');
|
||||||
|
dragHandleElement.draggable = true;
|
||||||
|
dragHandleElement.dataset.dragHandle = '';
|
||||||
|
dragHandleElement.classList.add('drag-handle');
|
||||||
|
dragHandleElement.addEventListener('dragstart', (e) => {
|
||||||
|
handleDragStart(e, view);
|
||||||
|
});
|
||||||
|
dragHandleElement.addEventListener('click', (e) => {
|
||||||
|
handleClick(e, view);
|
||||||
|
});
|
||||||
|
|
||||||
|
hideDragHandle();
|
||||||
|
|
||||||
|
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
dragHandleElement?.remove?.();
|
||||||
|
dragHandleElement = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
mousemove: (view, event) => {
|
||||||
|
if (!view.editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodeDOMAtCoords({
|
||||||
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) {
|
||||||
|
hideDragHandle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compStyle = window.getComputedStyle(node);
|
||||||
|
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
||||||
|
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||||
|
|
||||||
|
const rect = absoluteRect(node);
|
||||||
|
|
||||||
|
rect.top += (lineHeight - 24) / 2;
|
||||||
|
rect.top += paddingTop;
|
||||||
|
// Li markers
|
||||||
|
if (
|
||||||
|
node.matches('ul:not([data-type=taskList]) li, ol li')
|
||||||
|
) {
|
||||||
|
rect.left -= options.dragHandleWidth;
|
||||||
|
}
|
||||||
|
rect.width = options.dragHandleWidth;
|
||||||
|
|
||||||
|
if (!dragHandleElement) return;
|
||||||
|
|
||||||
|
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||||
|
dragHandleElement.style.top = `${rect.top}px`;
|
||||||
|
showDragHandle();
|
||||||
|
},
|
||||||
|
keydown: () => {
|
||||||
|
hideDragHandle();
|
||||||
|
},
|
||||||
|
mousewheel: () => {
|
||||||
|
hideDragHandle();
|
||||||
|
},
|
||||||
|
// dragging class is used for CSS
|
||||||
|
dragstart: (view) => {
|
||||||
|
view.dom.classList.add('dragging');
|
||||||
|
},
|
||||||
|
drop: (view) => {
|
||||||
|
view.dom.classList.remove('dragging');
|
||||||
|
},
|
||||||
|
dragend: (view) => {
|
||||||
|
view.dom.classList.remove('dragging');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragAndDropOptions {
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const DragAndDrop = Extension.create<DragAndDropOptions>({
|
||||||
|
name: 'dragAndDrop',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
DragHandle({
|
||||||
|
dragHandleWidth: 24,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DragAndDrop;
|
||||||
42
client/src/features/editor/extensions/slash-command.ts
Normal file
42
client/src/features/editor/extensions/slash-command.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { PluginKey } from '@tiptap/pm/state';
|
||||||
|
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
|
||||||
|
import renderItems from '@/features/editor/components/slash-menu/render-items';
|
||||||
|
import getSuggestionItems from '@/features/editor/components/slash-menu/menu-items';
|
||||||
|
|
||||||
|
export const slashMenuPluginKey = new PluginKey('slash-command');
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const Command = Extension.create({
|
||||||
|
name: 'slash-command',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: '/',
|
||||||
|
command: ({ editor, range, props }) => {
|
||||||
|
props.command({ editor, range, props });
|
||||||
|
},
|
||||||
|
} as Partial<SuggestionOptions>,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
pluginKey: slashMenuPluginKey,
|
||||||
|
...this.options.suggestion,
|
||||||
|
editor: this.editor,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const SlashCommand = Command.configure({
|
||||||
|
suggestion: {
|
||||||
|
items: getSuggestionItems,
|
||||||
|
render: renderItems,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SlashCommand;
|
||||||
69
client/src/features/editor/extensions/trailing-node.ts
Normal file
69
client/src/features/editor/extensions/trailing-node.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { PluginKey, Plugin } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
export interface TrailingNodeExtensionOptions {
|
||||||
|
node: string,
|
||||||
|
notAfter: string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeEqualsType({ types, node }: { types: any, node: any }) {
|
||||||
|
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
/**
|
||||||
|
* Extension based on:
|
||||||
|
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
|
||||||
|
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
|
||||||
|
*/
|
||||||
|
export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
|
||||||
|
name: 'trailingNode',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
node: 'paragraph',
|
||||||
|
notAfter: [
|
||||||
|
'paragraph',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const plugin = new PluginKey(this.name)
|
||||||
|
const disabledNodes = Object.entries(this.editor.schema.nodes)
|
||||||
|
.map(([, value]) => value)
|
||||||
|
.filter(node => this.options.notAfter.includes(node.name))
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: plugin,
|
||||||
|
appendTransaction: (_, __, state) => {
|
||||||
|
const { doc, tr, schema } = state;
|
||||||
|
const shouldInsertNodeAtEnd = plugin.getState(state);
|
||||||
|
const endPosition = doc.content.size;
|
||||||
|
const type = schema.nodes[this.options.node]
|
||||||
|
|
||||||
|
if (!shouldInsertNodeAtEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr.insert(endPosition, type.create());
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
init: (_, state) => {
|
||||||
|
const lastNode = state.tr.doc.lastChild
|
||||||
|
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||||
|
},
|
||||||
|
apply: (tr, value) => {
|
||||||
|
if (!tr.docChanged) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNode = tr.doc.lastChild
|
||||||
|
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
26
client/src/features/editor/styles/collaboration.css
Normal file
26
client/src/features/editor/styles/collaboration.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/* Give a remote user a caret */
|
||||||
|
.collaboration-cursor__caret {
|
||||||
|
border-left: 1px solid #0d0d0d;
|
||||||
|
border-right: 1px solid #0d0d0d;
|
||||||
|
margin-left: -1px;
|
||||||
|
margin-right: -1px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Render the username above the caret */
|
||||||
|
.collaboration-cursor__label {
|
||||||
|
border-radius: 3px 3px 3px 0;
|
||||||
|
color: #0d0d0d;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
left: -1px;
|
||||||
|
line-height: normal;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
position: absolute;
|
||||||
|
top: -1.4em;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
96
client/src/features/editor/styles/core.css
Normal file
96
client/src/features/editor/styles/core.css
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
.ProseMirror {
|
||||||
|
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||||
|
color: light-dark(var(--mantine-color-default-color), var(--mantine-color-dark-0));
|
||||||
|
font-size: var(--mantine-font-size-md);
|
||||||
|
line-height: var(--mantine-line-height-lg);
|
||||||
|
font-weight: 400;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||||
|
color: #616161;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: var(--mantine-spacing-xs);
|
||||||
|
margin: var(--mantine-spacing-md) 0;
|
||||||
|
font-family: var(--mantine-font-family-monospace);
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
color: var(--mantine-color-black);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
color: var(--mantine-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 25px;
|
||||||
|
border-left: 2px solid var(--mantine-color-gray-6);
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid #ced4da;
|
||||||
|
margin: 2rem 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr.ProseMirror-selectednode {
|
||||||
|
border-top: 1px solid #68CEF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-selectednode {
|
||||||
|
outline: 2px solid #70CFF8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
45
client/src/features/editor/styles/drag-handle.css
Normal file
45
client/src/features/editor/styles/drag-handle.css
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
.ProseMirror:not(.dragging) {
|
||||||
|
.ProseMirror-selectednode {
|
||||||
|
outline: none !important;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background-color: rgba(150, 170, 220, 0.2);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: fixed;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity ease-in 0.2s;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
z-index: 50;
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(55, 53, 47, 0.3)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #0d0d0d10;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,199 +0,0 @@
|
|||||||
/* Basic editor styles */
|
|
||||||
.tiptap {
|
|
||||||
> * + * {
|
|
||||||
margin-top: 0.75em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: rgba(#616161, 0.1);
|
|
||||||
color: #616161;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: #0d0d0d;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: #fff;
|
|
||||||
font-family: "JetBrainsMono", monospace;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mark {
|
|
||||||
background-color: #faf594;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul[data-type="taskList"] {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
li {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
> label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> div {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
background-color: #fff;
|
|
||||||
border: 2px solid #0d0d0d;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
box-shadow: 5px 5px #000;
|
|
||||||
color: #0d0d0d;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 26rem;
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 2px solid #0d0d0d;
|
|
||||||
border-top-left-radius: 0.25rem;
|
|
||||||
border-top-right-radius: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__users {
|
|
||||||
color: rgba(#000, 0.8);
|
|
||||||
display: flex;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1.25rem 1rem;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Some information about the status */
|
|
||||||
&__status {
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
background: rgba(#0d0d0d, 0.5);
|
|
||||||
border-radius: 50%;
|
|
||||||
content: " ";
|
|
||||||
display: inline-block;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 0.5rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
width: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--connecting::before {
|
|
||||||
background: #616161;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--connected::before {
|
|
||||||
background: #b9f18d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name button {
|
|
||||||
appearance: none;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
line-height: normal;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: visible;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Give a remote user a caret */
|
|
||||||
.collaboration-cursor__caret {
|
|
||||||
border-left: 1px solid #0d0d0d;
|
|
||||||
border-right: 1px solid #0d0d0d;
|
|
||||||
margin-left: -1px;
|
|
||||||
margin-right: -1px;
|
|
||||||
pointer-events: none;
|
|
||||||
position: relative;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Render the username above the caret */
|
|
||||||
.collaboration-cursor__label {
|
|
||||||
border-radius: 3px 3px 3px 0;
|
|
||||||
color: #0d0d0d;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
left: -1px;
|
|
||||||
line-height: normal;
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
position: absolute;
|
|
||||||
top: -1.4em;
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
background: #000;
|
|
||||||
border-radius: 100%;
|
|
||||||
height: 0.625rem;
|
|
||||||
width: 0.625rem;
|
|
||||||
}
|
|
||||||
7
client/src/features/editor/styles/editor.module.css
Normal file
7
client/src/features/editor/styles/editor.module.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.editor {
|
||||||
|
max-width: 800px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px 20px;
|
||||||
|
margin: 64px auto;
|
||||||
|
}
|
||||||
|
|
||||||
5
client/src/features/editor/styles/index.css
Normal file
5
client/src/features/editor/styles/index.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@import 'core.css';
|
||||||
|
@import 'collaboration.css';
|
||||||
|
@import 'task-list.css';
|
||||||
|
@import 'placeholder.css';
|
||||||
|
@import 'drag-handle.css';
|
||||||
24
client/src/features/editor/styles/placeholder.css
Normal file
24
client/src/features/editor/styles/placeholder.css
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h1.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder (on every new line) */
|
||||||
|
/*.ProseMirror p.is-empty::before {
|
||||||
|
color: #adb5bd;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}*/
|
||||||
31
client/src/features/editor/styles/task-list.css
Normal file
31
client/src/features/editor/styles/task-list.css
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
ul[data-type="taskList"] {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li,
|
||||||
|
ol li {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type="taskList"] > li {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import styles from './styles/tree.module.css';
|
import styles from './styles/tree.module.css';
|
||||||
@ -33,6 +33,8 @@ export default function PageTree() {
|
|||||||
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
|
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
|
||||||
const { data: pageOrderData } = useWorkspacePageOrder();
|
const { data: pageOrderData } = useWorkspacePageOrder();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
|
||||||
const fetchAndSetTreeData = async () => {
|
const fetchAndSetTreeData = async () => {
|
||||||
if (pageOrderData?.childrenIds) {
|
if (pageOrderData?.childrenIds) {
|
||||||
@ -58,7 +60,7 @@ export default function PageTree() {
|
|||||||
}, [tree, location.pathname]);
|
}, [tree, location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.treeContainer}>
|
<div ref={rootElement} className={styles.treeContainer}>
|
||||||
<FillFlexParent>
|
<FillFlexParent>
|
||||||
{(dimens) => (
|
{(dimens) => (
|
||||||
<Tree
|
<Tree
|
||||||
@ -74,6 +76,7 @@ export default function PageTree() {
|
|||||||
padding={15}
|
padding={15}
|
||||||
rowHeight={30}
|
rowHeight={30}
|
||||||
overscanCount={5}
|
overscanCount={5}
|
||||||
|
dndRootElement={rootElement.current}
|
||||||
>
|
>
|
||||||
{Node}
|
{Node}
|
||||||
</Tree>
|
</Tree>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/spotlight/styles.css';
|
import '@mantine/spotlight/styles.css';
|
||||||
import '@mantine/tiptap/styles.css';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import Editor from '@/features/editor/editor';
|
import React, { Suspense } from 'react';
|
||||||
|
|
||||||
|
const Editor = React.lazy(() => import('@/features/editor/editor'));
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { pageId } = useParams();
|
const { pageId } = useParams();
|
||||||
|
|
||||||
return <Editor key={pageId} pageId={pageId} />;
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<Editor key={pageId} pageId={pageId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user