work on editor

This commit is contained in:
Philipinho
2023-10-26 00:35:49 +01:00
parent 968b9c3a0f
commit fd2ef3a906
27 changed files with 1616 additions and 271 deletions

View File

@ -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',
}, },
} }

View File

@ -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",

View File

@ -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>
); );
} }

View File

@ -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));
}
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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);
}
}

View 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[]>;

View File

@ -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>
); );
} }

View 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;

View 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;

View 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 })
},
},
}),
]
}
})

View 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;
}

View 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;
}

View 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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
.editor {
max-width: 800px;
height: 100%;
padding: 8px 20px;
margin: 64px auto;
}

View File

@ -0,0 +1,5 @@
@import 'core.css';
@import 'collaboration.css';
@import 'task-list.css';
@import 'placeholder.css';
@import 'drag-handle.css';

View 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;
}*/

View 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;
}
}
}

View File

@ -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>

View File

@ -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';

View File

@ -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>
);
} }