mirror of
https://github.com/docmost/docmost.git
synced 2025-11-19 18:21:09 +10:00
switch to nx monorepo
This commit is contained in:
6
apps/client/src/features/editor/atoms/editor-atoms.ts
Normal file
6
apps/client/src/features/editor/atoms/editor-atoms.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { atom } from 'jotai';
|
||||
import { Editor } from '@tiptap/core';
|
||||
|
||||
export const pageEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const titleEditorAtom = atom<Editor | null>(null);
|
||||
@ -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,149 @@
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from '@tiptap/react';
|
||||
import { FC, useState } from 'react';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline, IconMessage } 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';
|
||||
import { draftCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof IconBold;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
|
||||
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 commentItem: BubbleMenuItem = {
|
||||
|
||||
name: 'comment',
|
||||
isActive: () => props.editor.isActive('comment'),
|
||||
command: () => {
|
||||
const commentId = uuidv4();
|
||||
|
||||
props.editor.chain().focus().setCommentDecoration().run();
|
||||
setDraftCommentId(commentId);
|
||||
setShowCommentPopup(true);
|
||||
},
|
||||
icon: IconMessage,
|
||||
};
|
||||
|
||||
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} withArrow>
|
||||
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip label={commentItem.name} withArrow>
|
||||
|
||||
<ActionIcon variant="default" size="lg" radius="0" aria-label={commentItem.name}
|
||||
style={{ border: 'none' }}
|
||||
onClick={commentItem.command}>
|
||||
<IconMessage style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
</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 React, { 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: React.ElementType;
|
||||
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 size={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,39 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Skeleton } from '@mantine/core';
|
||||
|
||||
function EditorSkeleton() {
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShowSkeleton(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!showSkeleton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
<Skeleton height={12} mt={6} radius="xl" />
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorSkeleton;
|
||||
@ -0,0 +1,132 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
SlashMenuGroupedItemsType,
|
||||
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: SlashMenuGroupedItemsType;
|
||||
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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
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 = {
|
||||
[category: string]: SlashMenuItemType[];
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { commentDecorationMetaKey, commentMarkClass } from '@/features/editor/extensions/comment/comment';
|
||||
|
||||
export function commentDecoration(): Plugin {
|
||||
const commentDecorationPlugin = new PluginKey('commentDecoration');
|
||||
|
||||
return new Plugin({
|
||||
key: commentDecorationPlugin,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, oldSet) {
|
||||
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
|
||||
|
||||
if (decorationMeta) {
|
||||
const { from, to } = tr.selection;
|
||||
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
|
||||
return DecorationSet.create(tr.doc, [decoration]);
|
||||
} else if (decorationMeta === false) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
return oldSet.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
return commentDecorationPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
137
apps/client/src/features/editor/extensions/comment/comment.ts
Normal file
137
apps/client/src/features/editor/extensions/comment/comment.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||
import { commentDecoration } from '@/features/editor/extensions/comment/comment-decoration';
|
||||
|
||||
export interface ICommentOptions {
|
||||
HTMLAttributes: Record<string, any>,
|
||||
}
|
||||
|
||||
export interface ICommentStorage {
|
||||
activeCommentId: string | null;
|
||||
}
|
||||
|
||||
export const commentMarkClass = 'comment-mark';
|
||||
export const commentDecorationMetaKey = 'decorateComment';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
comment: {
|
||||
setCommentDecoration: () => ReturnType,
|
||||
unsetCommentDecoration: () => ReturnType,
|
||||
setComment: (commentId: string) => ReturnType,
|
||||
unsetComment: (commentId: string) => ReturnType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
name: 'comment',
|
||||
exitable: true,
|
||||
inclusive: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
activeCommentId: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-comment-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.commentId) return;
|
||||
|
||||
return {
|
||||
'data-comment-id': attributes.commentId,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-comment-id]',
|
||||
getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, true);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
unsetCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, false);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
setComment: (commentId) => ({ commands }) => {
|
||||
if (!commentId) return false;
|
||||
return commands.setMark(this.name, { commentId });
|
||||
},
|
||||
unsetComment:
|
||||
(commentId) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(mark =>
|
||||
mark.type.name === this.name && mark.attrs.commentId === commentId);
|
||||
|
||||
if (commentMark) {
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const commentId = HTMLAttributes?.['data-comment-id'] || null;
|
||||
const elem = document.createElement('span');
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||
|
||||
elem.addEventListener('click', (e) => {
|
||||
const selection = document.getSelection();
|
||||
if (selection.type === 'Range') return;
|
||||
|
||||
this.storage.activeCommentId = commentId;
|
||||
const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', {
|
||||
bubbles: true,
|
||||
detail: { commentId },
|
||||
});
|
||||
|
||||
elem.dispatchEvent(commentEventClick);
|
||||
});
|
||||
|
||||
return elem;
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
// @ts-ignore
|
||||
return [commentDecoration()];
|
||||
},
|
||||
|
||||
},
|
||||
);
|
||||
223
apps/client/src/features/editor/extensions/drag-handle.ts
Normal file
223
apps/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;
|
||||
67
apps/client/src/features/editor/extensions/extensions.ts
Normal file
67
apps/client/src/features/editor/extensions/extensions.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import SlashCommand from '@/features/editor/extensions/slash-command';
|
||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||
import { Comment } from '@/features/editor/extensions/comment/comment';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
Comment.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'comment-mark',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider) => any[];
|
||||
|
||||
export const collabExtensions: CollabExtensions = (provider) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
42
apps/client/src/features/editor/extensions/slash-command.ts
Normal file
42
apps/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
apps/client/src/features/editor/extensions/trailing-node.ts
Normal file
69
apps/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 })
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
})
|
||||
20
apps/client/src/features/editor/full-editor.tsx
Normal file
20
apps/client/src/features/editor/full-editor.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import classes from '@/features/editor/styles/editor.module.css';
|
||||
import React from 'react';
|
||||
import { TitleEditor } from '@/features/editor/title-editor';
|
||||
import PageEditor from '@/features/editor/page-editor';
|
||||
|
||||
export interface FullEditorProps {
|
||||
pageId: string;
|
||||
title: any;
|
||||
}
|
||||
|
||||
export function FullEditor({ pageId, title }: FullEditorProps) {
|
||||
|
||||
return (
|
||||
<div className={classes.editor}>
|
||||
<TitleEditor pageId={pageId} title={title} />
|
||||
<PageEditor pageId={pageId} />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
const useCollaborationURL = (): string => {
|
||||
const PATH = "/collaboration";
|
||||
|
||||
if (import.meta.env.VITE_COLLABORATION_URL) {
|
||||
return import.meta.env.VITE_COLLABORATION_URL + PATH;
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_BACKEND_API_URL;
|
||||
if (!API_URL) {
|
||||
throw new Error("Backend API URL is not defined");
|
||||
}
|
||||
|
||||
const wsProtocol = API_URL.startsWith('https') ? 'wss' : 'ws';
|
||||
return `${wsProtocol}://${API_URL.split('://')[1]}${PATH}`;
|
||||
};
|
||||
|
||||
export default useCollaborationURL;
|
||||
170
apps/client/src/features/editor/page-editor.tsx
Normal file
170
apps/client/src/features/editor/page-editor.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import * as Y from 'yjs';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { collabExtensions, mainExtensions } from '@/features/editor/extensions/extensions';
|
||||
import { useAtom } from 'jotai';
|
||||
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
||||
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms';
|
||||
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { activeCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import CommentDialog from '@/features/comment/components/comment-dialog';
|
||||
import EditorSkeleton from '@/features/editor/components/editor-skeleton';
|
||||
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
|
||||
|
||||
const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D'];
|
||||
const getRandomElement = list => list[Math.floor(Math.random() * list.length)];
|
||||
const getRandomColor = () => getRandomElement(colors);
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export default function PageEditor({ pageId, editable = true }: PageEditorProps) {
|
||||
const [token] = useAtom(authTokensAtom);
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
|
||||
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(
|
||||
pageId,
|
||||
ydoc,
|
||||
);
|
||||
|
||||
provider.on('synced', () => {
|
||||
setLocalSynced(true);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
|
||||
const remoteProvider = useMemo(() => {
|
||||
const provider = new HocuspocusProvider({
|
||||
name: pageId,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: token?.accessToken,
|
||||
connect: false,
|
||||
});
|
||||
|
||||
provider.on('synced', () => {
|
||||
setRemoteSynced(true);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [ydoc, pageId, token?.accessToken]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
remoteProvider.connect();
|
||||
|
||||
return () => {
|
||||
setRemoteSynced(false);
|
||||
setLocalSynced(false);
|
||||
remoteProvider.destroy();
|
||||
localProvider.destroy();
|
||||
};
|
||||
}, [remoteProvider, localProvider]);
|
||||
|
||||
const extensions = [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider),
|
||||
];
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
editable,
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
|
||||
const slashCommand = document.querySelector('#slash-command');
|
||||
if (slashCommand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
}
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && currentUser.user) {
|
||||
editor.chain().focus().updateUser({ ...currentUser.user, color: getRandomColor() }).run();
|
||||
}
|
||||
}, [editor, currentUser.user]);
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId } = event.detail;
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: 'comments', isAsideOpen: true });
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
return () => {
|
||||
document.removeEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCommentId(null);
|
||||
setShowCommentPopup(false);
|
||||
setAsideState({ tab: '', isAsideOpen: false });
|
||||
}, [pageId]);
|
||||
|
||||
const isSynced = isLocalSynced || isRemoteSynced;
|
||||
|
||||
return isSynced ? (
|
||||
<div>
|
||||
{isSynced && (
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : <EditorSkeleton />;
|
||||
|
||||
}
|
||||
26
apps/client/src/features/editor/styles/collaboration.css
Normal file
26
apps/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;
|
||||
}
|
||||
100
apps/client/src/features/editor/styles/core.css
Normal file
100
apps/client/src/features/editor/styles/core.css
Normal file
@ -0,0 +1,100 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
background: rgba(0,203,15,0.2);
|
||||
border-bottom: 2px solid #0ca678;
|
||||
}
|
||||
45
apps/client/src/features/editor/styles/drag-handle.css
Normal file
45
apps/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;
|
||||
}
|
||||
}
|
||||
7
apps/client/src/features/editor/styles/editor.module.css
Normal file
7
apps/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
apps/client/src/features/editor/styles/index.css
Normal file
5
apps/client/src/features/editor/styles/index.css
Normal file
@ -0,0 +1,5 @@
|
||||
@import './core';
|
||||
@import './collaboration';
|
||||
@import './task-list';
|
||||
@import './placeholder';
|
||||
@import './drag-handle';
|
||||
24
apps/client/src/features/editor/styles/placeholder.css
Normal file
24
apps/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
apps/client/src/features/editor/styles/task-list.css
Normal file
31
apps/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
apps/client/src/features/editor/title-editor.tsx
Normal file
93
apps/client/src/features/editor/title-editor.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Heading } from '@tiptap/extension-heading';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { pageEditorAtom, titleEditorAtom } from '@/features/editor/atoms/editor-atoms';
|
||||
import { useUpdatePageMutation } from '@/features/page/queries/page-query';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import { updateTreeNodeName } from '@/features/page/tree/utils';
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
title: any;
|
||||
}
|
||||
|
||||
export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState('');
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
Document.extend({
|
||||
content: 'heading',
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: 'Untitled',
|
||||
}),
|
||||
],
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setTitleEditor(editor);
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
const currentTitle = editor.getText();
|
||||
setDebouncedTitleState(currentTitle);
|
||||
},
|
||||
content: title,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== '') {
|
||||
updatePageMutation.mutate({ id: pageId, title: debouncedTitle });
|
||||
|
||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
titleEditor?.commands.focus('end');
|
||||
}, 500);
|
||||
}, [titleEditor]);
|
||||
|
||||
function handleTitleKeyDown(event) {
|
||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
|
||||
(key === 'ArrowRight' && !$head.nodeAfter);
|
||||
|
||||
if (shouldFocusEditor) {
|
||||
pageEditor.commands.focus('start');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user