mirror of
https://github.com/docmost/docmost.git
synced 2025-11-19 06:21:10 +10:00
switch to nx monorepo
This commit is contained in:
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user