feat: page history

This commit is contained in:
Philipinho
2023-11-22 20:42:34 +00:00
parent 21347e6c42
commit 3f9b6e1380
50 changed files with 995 additions and 200 deletions

View File

@@ -2,3 +2,8 @@ import { atom } from 'jotai';
import { Editor } from '@tiptap/core';
export const editorAtom = atom<Editor | null>(null);
export const titleEditorAtom = atom<Editor | null>(null);
export type EditorAtomType = typeof editorAtom;
export type TitleEditorAtomType = typeof titleEditorAtom;

View File

@@ -20,8 +20,8 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
const [draftCommentId, setDraftCommentId] = useAtom<string | null>(draftCommentIdAtom);
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const items: BubbleMenuItem[] = [
{

View File

@@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core';
import { Dispatch, FC, SetStateAction } from 'react';
import React, { Dispatch, FC, SetStateAction } from 'react';
import {
IconBlockquote,
IconCheck, IconCheckbox, IconChevronDown, IconCode,
@@ -21,7 +21,7 @@ interface NodeSelectorProps {
export interface BubbleMenuItem {
name: string;
icon: FC;
icon: React.ElementType;
command: () => void;
isActive: () => boolean;
}
@@ -126,7 +126,7 @@ export const NodeSelector: FC<NodeSelectorProps> =
variant="default"
leftSection={<item.icon size={16} />}
rightSection={activeItem.name === item.name
&& (<IconCheck style={{ width: rem(16) }} />)}
&& (<IconCheck size={16} />)}
justify="left"
fullWidth
onClick={() => {

View File

@@ -6,6 +6,7 @@ import {
useState,
} from 'react';
import {
SlashMenuGroupedItemsType,
SlashMenuItemType,
} from '@/features/editor/components/slash-menu/types';
import {
@@ -24,7 +25,7 @@ const CommandList = ({
editor,
range,
}: {
items: SlashMenuItemType[];
items: SlashMenuGroupedItemsType;
command: any;
editor: any;
range: any;

View File

@@ -22,4 +22,6 @@ export type SlashMenuItemType = {
disable?: (editor: Editor) => boolean;
}
export type SlashMenuGroupedItemsType = Record<string, SlashMenuItemType[]>;
export type SlashMenuGroupedItemsType = {
[category: string]: SlashMenuItemType[];
};

View File

@@ -1,29 +1,20 @@
import '@/features/editor/styles/index.css';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { EditorContent, useEditor } from '@tiptap/react';
import { Placeholder } from '@tiptap/extension-placeholder';
import React, { useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
import { IndexeddbPersistence } from 'y-indexeddb';
import classes from '@/features/editor/styles/editor.module.css';
import '@/features/editor/styles/index.css';
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
import { Document } from '@tiptap/extension-document';
import { Text } from '@tiptap/extension-text';
import { Heading } from '@tiptap/extension-heading';
import { useDebouncedValue } from '@mantine/hooks';
import { pageAtom } from '@/features/page/atoms/page-atom';
import { IPage } from '@/features/page/types/page.types';
import { Comment } from '@/features/editor/extensions/comment/comment';
import { desktopAsideAtom } from '@/components/navbar/atoms/sidebar-atom';
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 { editorAtom } from '@/features/editor/atoms/editorAtom';
import { editorAtom, titleEditorAtom } from '@/features/editor/atoms/editorAtom';
import { collabExtensions, mainExtensions } from '@/features/editor/extensions';
import { useUpdatePageMutation } from '@/features/page/queries/page';
interface EditorProps {
pageId: string,
@@ -78,6 +69,9 @@ export default function Editor({ pageId }: EditorProps) {
}
const isSynced = isLocalSynced || isRemoteSynced;
if (isSynced){
window.scrollTo(0, 0);
}
return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} pageId={pageId} />);
}
@@ -90,61 +84,19 @@ interface TiptapEditorProps {
function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(editorAtom);
const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
const [debouncedTitleState, setDebouncedTitleState] = useState('');
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation();
const [desktopAsideOpened, setDesktopAsideOpened] = useAtom<boolean>(desktopAsideAtom);
const [activeCommentId, setActiveCommentId] = useAtom<string | null>(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
const titleEditor = useEditor({
extensions: [
Document.extend({
content: 'heading',
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: 'Untitled',
}),
],
onUpdate({ editor }) {
const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle);
},
content: page.title,
});
useEffect(() => {
setTimeout(() => {
titleEditor?.commands.focus('start');
window.scrollTo(0, 0);
}, 100);
}, []);
useEffect(() => {
if (debouncedTitle !== '') {
updatePageMutation.mutate({ id: pageId, title: debouncedTitle });
}
}, [debouncedTitle]);
const [titleEditor] = useAtom(titleEditorAtom);
const [asideState, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const extensions = [
...mainExtensions,
...collabExtensions(ydoc, provider),
Comment.configure({
HTMLAttributes: {
class: 'comment-mark',
},
}),
];
const editor = useEditor({
extensions: extensions,
autofocus: false,
autofocus: 0,
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
@@ -159,6 +111,7 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
},
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setEditor(editor);
}
},
@@ -177,30 +130,22 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
},
});
useEffect(() => {
setTimeout(() => {
titleEditor?.commands.focus('end');
}, 200);
}, [editor]);
useEffect(() => {
if (editor && currentUser.user) {
editor.chain().focus().updateUser({ ...currentUser.user, color: getRandomColor() }).run();
}
}, [editor, currentUser.user]);
function handleTitleKeyDown(event) {
if (!titleEditor || !editor || event.shiftKey) return;
const { key } = event;
const { $head } = titleEditor.state.selection;
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
(key === 'ArrowRight' && !$head.nodeAfter);
if (shouldFocusEditor) {
editor.commands.focus('start');
}
}
const handleActiveCommentEvent = (event) => {
const { commentId } = event.detail;
setActiveCommentId(commentId);
setDesktopAsideOpened(true);
setAsideState({ tab: 'comments', isAsideOpen: true });
const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector);
@@ -216,21 +161,22 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
useEffect(() => {
setActiveCommentId(null);
setDesktopAsideOpened(false);
setShowCommentPopup(false);
setAsideState({ tab: '', isAsideOpen: false });
}, [pageId]);
return (
<>
<div className={classes.editor}>
{editor && <EditorBubbleMenu editor={editor} />}
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
<EditorContent editor={editor} />
</div>
<div>
{editor &&
(<div>
<EditorBubbleMenu editor={editor} />
<EditorContent editor={editor} />
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
</>
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
</div>)}
</div>
);
}

View File

@@ -16,6 +16,7 @@ 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 * as Y from 'yjs';
export const mainExtensions = [
@@ -47,6 +48,11 @@ export const mainExtensions = [
TextStyle,
Color,
SlashCommand,
Comment.configure({
HTMLAttributes: {
class: 'comment-mark',
},
}),
];
type CollabExtensions = (ydoc: Y.Doc, provider: any) => any[];

View File

@@ -127,6 +127,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return elem;
},
// @ts-ignore
addProseMirrorPlugins(): Plugin[] {
// @ts-ignore
return [commentDecoration()];

View File

@@ -0,0 +1,20 @@
import classes from '@/features/editor/styles/editor.module.css';
import Editor from '@/features/editor/editor';
import React from 'react';
import { TitleEditor } from '@/features/editor/title-editor';
export interface FullEditorProps {
pageId: string;
title: any;
}
export function FullEditor({ pageId, title }: FullEditorProps) {
return (
<div className={classes.editor}>
<TitleEditor pageId={pageId} title={title} />
<Editor pageId={pageId} />
</div>
);
}

View File

@@ -0,0 +1,75 @@
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 { editorAtom, titleEditorAtom } from '@/features/editor/atoms/editorAtom';
import { useUpdatePageMutation } from '@/features/page/queries/page-query';
import { useDebouncedValue } from '@mantine/hooks';
import { useAtom } from 'jotai';
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 contentEditor = useAtomValue(editorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
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 });
}
}, [debouncedTitle]);
function handleTitleKeyDown(event) {
if (!titleEditor || !contentEditor || event.shiftKey) return;
const { key } = event;
const { $head } = titleEditor.state.selection;
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
(key === 'ArrowRight' && !$head.nodeAfter);
if (shouldFocusEditor) {
contentEditor.commands.focus('start');
}
}
return (
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
);
}