switch to nx monorepo

This commit is contained in:
Philipinho
2024-01-09 18:58:26 +01:00
parent e1bb2632b8
commit 093e634c0b
273 changed files with 11419 additions and 31 deletions

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

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

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

View File

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

View File

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

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,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[];
};

View File

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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