From fd2ef3a9061c237ef0e7ae6d39a0c0659c2dbec1 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 26 Oct 2023 00:35:49 +0100 Subject: [PATCH] work on editor --- client/.eslintrc.cjs | 2 + client/package.json | 13 +- client/src/components/layouts/shell.tsx | 10 +- .../bubble-menu/bubble-menu.module.css | 25 ++ .../components/bubble-menu/bubble-menu.tsx | 120 ++++++++++ .../components/bubble-menu/color-selector.tsx | 189 +++++++++++++++ .../components/bubble-menu/node-selector.tsx | 148 ++++++++++++ .../components/slash-menu/command-list.tsx | 131 ++++++++++ .../components/slash-menu/menu-items.ts | 163 +++++++++++++ .../components/slash-menu/render-items.ts | 66 ++++++ .../slash-menu/slash-menu.module.css | 21 ++ .../editor/components/slash-menu/types.ts | 25 ++ client/src/features/editor/editor.tsx | 189 ++++++++++----- .../features/editor/extensions/drag-handle.ts | 223 ++++++++++++++++++ .../editor/extensions/slash-command.ts | 42 ++++ .../editor/extensions/trailing-node.ts | 69 ++++++ .../features/editor/styles/collaboration.css | 26 ++ client/src/features/editor/styles/core.css | 96 ++++++++ .../features/editor/styles/drag-handle.css | 45 ++++ client/src/features/editor/styles/editor.css | 199 ---------------- .../features/editor/styles/editor.module.css | 7 + client/src/features/editor/styles/index.css | 5 + .../features/editor/styles/placeholder.css | 24 ++ .../src/features/editor/styles/task-list.css | 31 +++ client/src/features/page/tree/page-tree.tsx | 7 +- client/src/main.tsx | 1 - client/src/pages/page/page.tsx | 10 +- 27 files changed, 1616 insertions(+), 271 deletions(-) create mode 100644 client/src/features/editor/components/bubble-menu/bubble-menu.module.css create mode 100644 client/src/features/editor/components/bubble-menu/bubble-menu.tsx create mode 100644 client/src/features/editor/components/bubble-menu/color-selector.tsx create mode 100644 client/src/features/editor/components/bubble-menu/node-selector.tsx create mode 100644 client/src/features/editor/components/slash-menu/command-list.tsx create mode 100644 client/src/features/editor/components/slash-menu/menu-items.ts create mode 100644 client/src/features/editor/components/slash-menu/render-items.ts create mode 100644 client/src/features/editor/components/slash-menu/slash-menu.module.css create mode 100644 client/src/features/editor/components/slash-menu/types.ts create mode 100644 client/src/features/editor/extensions/drag-handle.ts create mode 100644 client/src/features/editor/extensions/slash-command.ts create mode 100644 client/src/features/editor/extensions/trailing-node.ts create mode 100644 client/src/features/editor/styles/collaboration.css create mode 100644 client/src/features/editor/styles/core.css create mode 100644 client/src/features/editor/styles/drag-handle.css delete mode 100644 client/src/features/editor/styles/editor.css create mode 100644 client/src/features/editor/styles/editor.module.css create mode 100644 client/src/features/editor/styles/index.css create mode 100644 client/src/features/editor/styles/placeholder.css create mode 100644 client/src/features/editor/styles/task-list.css diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs index bb62692d..21016626 100644 --- a/client/.eslintrc.cjs +++ b/client/.eslintrc.cjs @@ -15,5 +15,7 @@ module.exports = { { allowConstantExport: true }, ], '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unused-vars': 'off', }, } diff --git a/client/package.json b/client/package.json index 1aa51ca0..f9b85864 100644 --- a/client/package.json +++ b/client/package.json @@ -14,24 +14,34 @@ "@mantine/form": "^7.1.5", "@mantine/hooks": "^7.1.5", "@mantine/spotlight": "^7.1.5", - "@mantine/tiptap": "^7.1.5", "@tabler/icons-react": "^2.39.0", "@tanstack/react-query": "^4.36.1", "@tanstack/react-table": "^8.10.7", + "@tiptap/extension-code-block": "^2.1.12", "@tiptap/extension-collaboration": "^2.1.12", "@tiptap/extension-collaboration-cursor": "^2.1.12", + "@tiptap/extension-color": "^2.1.12", "@tiptap/extension-document": "^2.1.12", "@tiptap/extension-heading": "^2.1.12", "@tiptap/extension-highlight": "^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-subscript": "^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-style": "^2.1.12", + "@tiptap/extension-typography": "^2.1.12", "@tiptap/extension-underline": "^2.1.12", "@tiptap/pm": "^2.1.12", "@tiptap/react": "^2.1.12", "@tiptap/starter-kit": "^2.1.12", + "@tiptap/suggestion": "^2.1.12", "axios": "^1.5.1", "clsx": "^2.0.0", "jotai": "^2.4.3", @@ -43,6 +53,7 @@ "react-hot-toast": "^2.4.1", "react-router-dom": "^6.17.0", "socket.io-client": "^4.7.2", + "tippy.js": "^6.3.7", "uuid": "^9.0.1", "y-indexeddb": "^9.0.11", "yjs": "^13.6.8", diff --git a/client/src/components/layouts/shell.tsx b/client/src/components/layouts/shell.tsx index 88bc30a6..2252fc5e 100644 --- a/client/src/components/layouts/shell.tsx +++ b/client/src/components/layouts/shell.tsx @@ -19,6 +19,7 @@ export default function Shell({ children }: { children: React.ReactNode }) { breakpoint: 'sm', collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, }} + aside={{ width: 300, breakpoint: 'md', collapsed: { desktop: false, mobile: true } }} padding="md" > @@ -45,7 +46,14 @@ export default function Shell({ children }: { children: React.ReactNode }) { - {children} + + + {children} + + + + TODO + ); } diff --git a/client/src/features/editor/components/bubble-menu/bubble-menu.module.css b/client/src/features/editor/components/bubble-menu/bubble-menu.module.css new file mode 100644 index 00000000..4189274d --- /dev/null +++ b/client/src/features/editor/components/bubble-menu/bubble-menu.module.css @@ -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)); + } + +} diff --git a/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/client/src/features/editor/components/bubble-menu/bubble-menu.tsx new file mode 100644 index 00000000..b173c5e3 --- /dev/null +++ b/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -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; + +export const EditorBubbleMenu: FC = (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 ( + + { + setIsNodeSelectorOpen(!isNodeSelectorOpen); + setIsColorSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + + + {items.map((item, index) => ( + + + + + + + + ))} + + + { + setIsColorSelectorOpen(!isColorSelectorOpen); + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + + + ); +}; diff --git a/client/src/features/editor/components/bubble-menu/color-selector.tsx b/client/src/features/editor/components/bubble-menu/color-selector.tsx new file mode 100644 index 00000000..bfcbfac2 --- /dev/null +++ b/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -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>; +} + +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 = + ({ editor, isOpen, setIsOpen }) => { + + const activeColorItem = TEXT_COLORS.find(({ color }) => + editor.isActive('textStyle', { color }), + ); + + const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => + editor.isActive('highlight', { color }), + ); + + return ( + + + + + ))} + + + BACKGROUND + + + {HIGHLIGHT_COLORS.map(({ name, color }, index) => ( + + ))} + + + + + + ); + }; diff --git a/client/src/features/editor/components/bubble-menu/node-selector.tsx b/client/src/features/editor/components/bubble-menu/node-selector.tsx new file mode 100644 index 00000000..179cf4b9 --- /dev/null +++ b/client/src/features/editor/components/bubble-menu/node-selector.tsx @@ -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>; +} + +export interface BubbleMenuItem { + name: string; + icon: FC; + command: () => void; + isActive: () => boolean; +} + +export const NodeSelector: FC = + ({ 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 ( + + + + + + + + + + + + + {items.map((item, index) => ( + + + ))} + + + + + + + ); + }; diff --git a/client/src/features/editor/components/slash-menu/command-list.tsx b/client/src/features/editor/components/slash-menu/command-list.tsx new file mode 100644 index 00000000..61469a53 --- /dev/null +++ b/client/src/features/editor/components/slash-menu/command-list.tsx @@ -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(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 ? ( + + + {Object.entries(items).map(([category, categoryItems]) => ( +
+ + {category} + + {categoryItems.map((item: SlashMenuItemType, index: number) => ( + 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)', + }} + > + + + +
+ + {item.title} + + + + {item.description} + +
+
+
+ ))} +
+ ))} +
+
+ ) : null; +}; + +export default CommandList; diff --git a/client/src/features/editor/components/slash-menu/menu-items.ts b/client/src/features/editor/components/slash-menu/menu-items.ts new file mode 100644 index 00000000..51913069 --- /dev/null +++ b/client/src/features/editor/components/slash-menu/menu-items.ts @@ -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; diff --git a/client/src/features/editor/components/slash-menu/render-items.ts b/client/src/features/editor/components/slash-menu/render-items.ts new file mode 100644 index 00000000..479ed3a9 --- /dev/null +++ b/client/src/features/editor/components/slash-menu/render-items.ts @@ -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; diff --git a/client/src/features/editor/components/slash-menu/slash-menu.module.css b/client/src/features/editor/components/slash-menu/slash-menu.module.css new file mode 100644 index 00000000..5024073d --- /dev/null +++ b/client/src/features/editor/components/slash-menu/slash-menu.module.css @@ -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); + } +} diff --git a/client/src/features/editor/components/slash-menu/types.ts b/client/src/features/editor/components/slash-menu/types.ts new file mode 100644 index 00000000..754e7f51 --- /dev/null +++ b/client/src/features/editor/components/slash-menu/types.ts @@ -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; diff --git a/client/src/features/editor/editor.tsx b/client/src/features/editor/editor.tsx index c27ea681..53d3248f 100644 --- a/client/src/features/editor/editor.tsx +++ b/client/src/features/editor/editor.tsx @@ -1,5 +1,3 @@ -import '@/features/editor/styles/editor.css'; - import { HocuspocusProvider } from '@hocuspocus/provider'; import * as Y from 'yjs'; import { EditorContent, useEditor } from '@tiptap/react'; @@ -7,23 +5,35 @@ import { StarterKit } from '@tiptap/starter-kit'; import { Placeholder } from '@tiptap/extension-placeholder'; import { Collaboration } from '@tiptap/extension-collaboration'; import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'; -import { useEffect, useLayoutEffect, useState } from 'react'; +import { 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 { RichTextEditor } from '@mantine/tiptap'; import { TextAlign } from '@tiptap/extension-text-align'; import { Highlight } from '@tiptap/extension-highlight'; import { Superscript } from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; import { Link } from '@tiptap/extension-link'; 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 { pageId: string, - token?: string, } const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D']; @@ -35,11 +45,13 @@ export default function Editor({ pageId }: EditorProps) { const collaborationURL = useCollaborationUrl(); const [provider, setProvider] = useState(); const [yDoc] = useState(() => new Y.Doc()); - + const [isLocalSynced, setLocalSynced] = useState(false); + const [isRemoteSynced, setRemoteSynced] = useState(false); useEffect(() => { if (token) { - const indexeddbProvider = new IndexeddbPersistence(pageId, yDoc) + const indexeddbProvider = new IndexeddbPersistence(pageId, yDoc); + const provider = new HocuspocusProvider({ url: collaborationURL, name: pageId, @@ -47,12 +59,23 @@ export default function Editor({ pageId }: EditorProps) { 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 () => { - provider.destroy(); setProvider(null); + provider.destroy(); indexeddbProvider.destroy(); + setRemoteSynced(false); + setLocalSynced(false); }; } }, [pageId, token]); @@ -61,9 +84,8 @@ export default function Editor({ pageId }: EditorProps) { return null; } - return ( - - ); + const isSynced = isLocalSynced || isRemoteSynced; + return (isSynced && ); } interface TiptapEditorProps { @@ -77,9 +99,10 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { const extensions = [ StarterKit.configure({ history: false, - }), - Placeholder.configure({ - placeholder: 'Write here', + dropcursor: { + width: 3, + color: '#70CFF8', + }, }), Collaboration.configure({ document: ydoc, @@ -87,16 +110,80 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { CollaborationCursor.configure({ provider, }), + Placeholder.configure({ + placeholder: 'Enter "/" for commands', + }), + TextAlign.configure({ types: ['heading', 'paragraph'] }), + TaskList, + TaskItem.configure({ + nested: true, + }), Underline, Link, Superscript, SubScript, - Highlight, - TextAlign.configure({ types: ['heading', 'paragraph'] }), + Highlight.configure({ + 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({ 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(() => { @@ -105,57 +192,29 @@ function TiptapEditor({ ydoc, provider }: TiptapEditorProps) { } }, [editor, currentUser.user]); - useEffect(() => { - provider.on('status', event => { - console.log(event); - }); + function handleTitleKeyDown(event) { + if (!titleEditor || !editor || event.shiftKey) return; - }, [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 ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + <> +
+ {editor && } + + +
+ ); } diff --git a/client/src/features/editor/extensions/drag-handle.ts b/client/src/features/editor/extensions/drag-handle.ts new file mode 100644 index 00000000..20b66d96 --- /dev/null +++ b/client/src/features/editor/extensions/drag-handle.ts @@ -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({ + name: 'dragAndDrop', + + addProseMirrorPlugins() { + return [ + DragHandle({ + dragHandleWidth: 24, + }), + ]; + }, +}); + +export default DragAndDrop; diff --git a/client/src/features/editor/extensions/slash-command.ts b/client/src/features/editor/extensions/slash-command.ts new file mode 100644 index 00000000..d11c3ae6 --- /dev/null +++ b/client/src/features/editor/extensions/slash-command.ts @@ -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, + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + pluginKey: slashMenuPluginKey, + ...this.options.suggestion, + editor: this.editor, + }), + ]; + }, +}); + +const SlashCommand = Command.configure({ + suggestion: { + items: getSuggestionItems, + render: renderItems, + }, +}); + +export default SlashCommand; diff --git a/client/src/features/editor/extensions/trailing-node.ts b/client/src/features/editor/extensions/trailing-node.ts new file mode 100644 index 00000000..bb66ee78 --- /dev/null +++ b/client/src/features/editor/extensions/trailing-node.ts @@ -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({ + 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 }) + }, + }, + }), + ] + } +}) diff --git a/client/src/features/editor/styles/collaboration.css b/client/src/features/editor/styles/collaboration.css new file mode 100644 index 00000000..4a43ac25 --- /dev/null +++ b/client/src/features/editor/styles/collaboration.css @@ -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; +} diff --git a/client/src/features/editor/styles/core.css b/client/src/features/editor/styles/core.css new file mode 100644 index 00000000..d7387cc0 --- /dev/null +++ b/client/src/features/editor/styles/core.css @@ -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; +} + diff --git a/client/src/features/editor/styles/drag-handle.css b/client/src/features/editor/styles/drag-handle.css new file mode 100644 index 00000000..2be7a495 --- /dev/null +++ b/client/src/features/editor/styles/drag-handle.css @@ -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; + } +} diff --git a/client/src/features/editor/styles/editor.css b/client/src/features/editor/styles/editor.css deleted file mode 100644 index 7cf3608e..00000000 --- a/client/src/features/editor/styles/editor.css +++ /dev/null @@ -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; -} diff --git a/client/src/features/editor/styles/editor.module.css b/client/src/features/editor/styles/editor.module.css new file mode 100644 index 00000000..acc381a9 --- /dev/null +++ b/client/src/features/editor/styles/editor.module.css @@ -0,0 +1,7 @@ +.editor { + max-width: 800px; + height: 100%; + padding: 8px 20px; + margin: 64px auto; +} + diff --git a/client/src/features/editor/styles/index.css b/client/src/features/editor/styles/index.css new file mode 100644 index 00000000..1a014c55 --- /dev/null +++ b/client/src/features/editor/styles/index.css @@ -0,0 +1,5 @@ +@import 'core.css'; +@import 'collaboration.css'; +@import 'task-list.css'; +@import 'placeholder.css'; +@import 'drag-handle.css'; diff --git a/client/src/features/editor/styles/placeholder.css b/client/src/features/editor/styles/placeholder.css new file mode 100644 index 00000000..796ef128 --- /dev/null +++ b/client/src/features/editor/styles/placeholder.css @@ -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; +}*/ diff --git a/client/src/features/editor/styles/task-list.css b/client/src/features/editor/styles/task-list.css new file mode 100644 index 00000000..9a994c7e --- /dev/null +++ b/client/src/features/editor/styles/task-list.css @@ -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; + } + } +} diff --git a/client/src/features/page/tree/page-tree.tsx b/client/src/features/page/tree/page-tree.tsx index c055558f..488cd445 100644 --- a/client/src/features/page/tree/page-tree.tsx +++ b/client/src/features/page/tree/page-tree.tsx @@ -13,7 +13,7 @@ import { IconTrash, } from '@tabler/icons-react'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import clsx from 'clsx'; import styles from './styles/tree.module.css'; @@ -33,6 +33,8 @@ export default function PageTree() { const [tree, setTree] = useAtom>(treeApiAtom); const { data: pageOrderData } = useWorkspacePageOrder(); const location = useLocation(); + const rootElement = useRef(); + const fetchAndSetTreeData = async () => { if (pageOrderData?.childrenIds) { @@ -58,7 +60,7 @@ export default function PageTree() { }, [tree, location.pathname]); return ( -
+
{(dimens) => ( {Node} diff --git a/client/src/main.tsx b/client/src/main.tsx index b2f8cb8f..fb623536 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,6 +1,5 @@ import '@mantine/core/styles.css'; import '@mantine/spotlight/styles.css'; -import '@mantine/tiptap/styles.css'; import React from 'react'; import ReactDOM from 'react-dom/client'; diff --git a/client/src/pages/page/page.tsx b/client/src/pages/page/page.tsx index 9c8b87f7..621d5c3b 100644 --- a/client/src/pages/page/page.tsx +++ b/client/src/pages/page/page.tsx @@ -1,8 +1,14 @@ 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() { const { pageId } = useParams(); - return ; + return ( + Loading...
}> + + + ); }