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