1998 lines
49 KiB
TypeScript
1998 lines
49 KiB
TypeScript
'use client';
|
||
|
||
import type { Editor, Range } from '@tiptap/core';
|
||
import { mergeAttributes, Node } from '@tiptap/core';
|
||
import { CharacterCount } from '@tiptap/extension-character-count';
|
||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
|
||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||
import { Subscript } from '@tiptap/extension-subscript';
|
||
import { Superscript } from '@tiptap/extension-superscript';
|
||
import { Table } from '@tiptap/extension-table';
|
||
import { TableCell } from '@tiptap/extension-table-cell';
|
||
import { TableHeader } from '@tiptap/extension-table-header';
|
||
import { TableRow } from '@tiptap/extension-table-row';
|
||
import { TaskItem } from '@tiptap/extension-task-item';
|
||
import { TaskList } from '@tiptap/extension-task-list';
|
||
import { TextStyle } from '@tiptap/extension-text-style';
|
||
import { Typography } from '@tiptap/extension-typography';
|
||
import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||
import { PluginKey } from '@tiptap/pm/state';
|
||
import {
|
||
ReactRenderer,
|
||
EditorProvider as TiptapEditorProvider,
|
||
type EditorProviderProps as TiptapEditorProviderProps,
|
||
useCurrentEditor,
|
||
} from '@tiptap/react';
|
||
|
||
// BubbleMenu and FloatingMenu were removed in TipTap v3
|
||
// Creating stub types for backwards compatibility
|
||
type BubbleMenuProps = {
|
||
className?: string;
|
||
editor: any;
|
||
tippyOptions?: any;
|
||
children?: React.ReactNode;
|
||
[key: string]: any;
|
||
};
|
||
|
||
type FloatingMenuProps = {
|
||
className?: string;
|
||
editor: any;
|
||
tippyOptions?: any;
|
||
children?: React.ReactNode;
|
||
[key: string]: any;
|
||
};
|
||
|
||
// Stub implementations - these features are not available in TipTap v3
|
||
const BubbleMenu = ({ className, children, ...props }: BubbleMenuProps) => {
|
||
// Render as a simple div - the bubble menu functionality is not available in v3
|
||
return <div className={className}>{children}</div>;
|
||
};
|
||
|
||
const FloatingMenu = ({ className, children, ...props }: FloatingMenuProps) => {
|
||
// Render as a simple div - the floating menu functionality is not available in v3
|
||
return <div className={className}>{children}</div>;
|
||
};
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Command,
|
||
CommandEmpty,
|
||
CommandItem,
|
||
CommandList,
|
||
} from '@/components/ui/command';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from '@/components/ui/popover';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from '@/components/ui/tooltip';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export type { Editor, JSONContent } from '@tiptap/react';
|
||
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion';
|
||
import Fuse from 'fuse.js';
|
||
import { all, createLowlight } from 'lowlight';
|
||
import {
|
||
ArrowDownIcon,
|
||
ArrowLeftIcon,
|
||
ArrowRightIcon,
|
||
ArrowUpIcon,
|
||
BoldIcon,
|
||
BoltIcon,
|
||
CheckIcon,
|
||
CheckSquareIcon,
|
||
ChevronDownIcon,
|
||
CodeIcon,
|
||
ColumnsIcon,
|
||
MoreHorizontal as EllipsisIcon,
|
||
MoreVertical as EllipsisVerticalIcon,
|
||
ExternalLinkIcon,
|
||
Heading1Icon,
|
||
Heading2Icon,
|
||
Heading3Icon,
|
||
ItalicIcon,
|
||
ListIcon,
|
||
ListOrderedIcon,
|
||
type LucideIcon,
|
||
type LucideProps,
|
||
RemoveFormattingIcon,
|
||
RowsIcon,
|
||
StrikethroughIcon,
|
||
SubscriptIcon,
|
||
SuperscriptIcon,
|
||
TableCellsMergeIcon,
|
||
TableColumnsSplitIcon,
|
||
TableIcon,
|
||
TextIcon,
|
||
TextQuoteIcon,
|
||
TrashIcon,
|
||
UnderlineIcon,
|
||
} from 'lucide-react';
|
||
import type { FormEventHandler, HTMLAttributes, ReactNode } from 'react';
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import tippy, { type Instance as TippyInstance } from 'tippy.js';
|
||
|
||
interface SlashNodeAttrs {
|
||
id: string | null;
|
||
label?: string | null;
|
||
}
|
||
|
||
type SlashOptions<
|
||
SlashOptionSuggestionItem = unknown,
|
||
Attrs = SlashNodeAttrs,
|
||
> = {
|
||
HTMLAttributes: Record<string, unknown>;
|
||
renderText: (props: {
|
||
options: SlashOptions<SlashOptionSuggestionItem, Attrs>;
|
||
node: ProseMirrorNode;
|
||
}) => string;
|
||
renderHTML: (props: {
|
||
options: SlashOptions<SlashOptionSuggestionItem, Attrs>;
|
||
node: ProseMirrorNode;
|
||
}) => DOMOutputSpec;
|
||
deleteTriggerWithBackspace: boolean;
|
||
suggestion: Omit<
|
||
SuggestionOptions<SlashOptionSuggestionItem, Attrs>,
|
||
'editor'
|
||
>;
|
||
};
|
||
|
||
const SlashPluginKey = new PluginKey('slash');
|
||
|
||
export interface SuggestionItem {
|
||
title: string;
|
||
description: string;
|
||
icon: LucideIcon;
|
||
searchTerms: string[];
|
||
command: (props: { editor: Editor; range: Range }) => void;
|
||
}
|
||
|
||
export const defaultSlashSuggestions: SuggestionOptions<SuggestionItem>['items'] =
|
||
() => [
|
||
{
|
||
title: 'Text',
|
||
description: 'Just start typing with plain text.',
|
||
searchTerms: ['p', 'paragraph'],
|
||
icon: TextIcon,
|
||
command: ({ editor, range }) => {
|
||
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: CheckSquareIcon,
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||
},
|
||
},
|
||
{
|
||
title: 'Heading 1',
|
||
description: 'Big section heading.',
|
||
searchTerms: ['title', 'big', 'large'],
|
||
icon: Heading1Icon,
|
||
command: ({ editor, range }) => {
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.setNode('heading', { level: 1 })
|
||
.run();
|
||
},
|
||
},
|
||
{
|
||
title: 'Heading 2',
|
||
description: 'Medium section heading.',
|
||
searchTerms: ['subtitle', 'medium'],
|
||
icon: Heading2Icon,
|
||
command: ({ editor, range }) => {
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.setNode('heading', { level: 2 })
|
||
.run();
|
||
},
|
||
},
|
||
{
|
||
title: 'Heading 3',
|
||
description: 'Small section heading.',
|
||
searchTerms: ['subtitle', 'small'],
|
||
icon: Heading3Icon,
|
||
command: ({ editor, range }) => {
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.setNode('heading', { level: 3 })
|
||
.run();
|
||
},
|
||
},
|
||
{
|
||
title: 'Bullet List',
|
||
description: 'Create a simple bullet list.',
|
||
searchTerms: ['unordered', 'point'],
|
||
icon: ListIcon,
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||
},
|
||
},
|
||
{
|
||
title: 'Numbered List',
|
||
description: 'Create a list with numbering.',
|
||
searchTerms: ['ordered'],
|
||
icon: ListOrderedIcon,
|
||
command: ({ editor, range }) => {
|
||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||
},
|
||
},
|
||
{
|
||
title: 'Quote',
|
||
description: 'Capture a quote.',
|
||
searchTerms: ['blockquote'],
|
||
icon: TextQuoteIcon,
|
||
command: ({ editor, range }) =>
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.toggleNode('paragraph', 'paragraph')
|
||
.toggleBlockquote()
|
||
.run(),
|
||
},
|
||
{
|
||
title: 'Code',
|
||
description: 'Capture a code snippet.',
|
||
searchTerms: ['codeblock'],
|
||
icon: CodeIcon,
|
||
command: ({ editor, range }) =>
|
||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||
},
|
||
{
|
||
title: 'Table',
|
||
description: 'Add a table view to organize data.',
|
||
searchTerms: ['table'],
|
||
icon: TableIcon,
|
||
command: ({ editor, range }) =>
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange(range)
|
||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||
.run(),
|
||
},
|
||
];
|
||
|
||
const Slash = Node.create<SlashOptions>({
|
||
name: 'slash',
|
||
priority: 101,
|
||
addOptions() {
|
||
return {
|
||
HTMLAttributes: {},
|
||
renderText({ options, node }) {
|
||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||
},
|
||
deleteTriggerWithBackspace: false,
|
||
renderHTML({ options, node }) {
|
||
return [
|
||
'span',
|
||
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
|
||
`${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
|
||
];
|
||
},
|
||
suggestion: {
|
||
char: '/',
|
||
pluginKey: SlashPluginKey,
|
||
command: ({ editor, range, props }) => {
|
||
// increase range.to by one when the next node is of type "text"
|
||
// and starts with a space character
|
||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||
const overrideSpace = nodeAfter?.text?.startsWith(' ');
|
||
|
||
if (overrideSpace) {
|
||
range.to += 1;
|
||
}
|
||
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.insertContentAt(range, [
|
||
{
|
||
type: this.name,
|
||
attrs: props,
|
||
},
|
||
{
|
||
type: 'text',
|
||
text: ' ',
|
||
},
|
||
])
|
||
.run();
|
||
|
||
// get reference to `window` object from editor element, to support cross-frame JS usage
|
||
editor.view.dom.ownerDocument.defaultView
|
||
?.getSelection()
|
||
?.collapseToEnd();
|
||
},
|
||
allow: ({ state, range }) => {
|
||
const $from = state.doc.resolve(range.from);
|
||
const type = state.schema.nodes[this.name];
|
||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||
|
||
return allow;
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
group: 'inline',
|
||
|
||
inline: true,
|
||
|
||
selectable: false,
|
||
|
||
atom: true,
|
||
|
||
addAttributes() {
|
||
return {
|
||
id: {
|
||
default: null,
|
||
parseHTML: (element) => element.getAttribute('data-id'),
|
||
renderHTML: (attributes) => {
|
||
if (!attributes.id) {
|
||
return {};
|
||
}
|
||
|
||
return {
|
||
'data-id': attributes.id,
|
||
};
|
||
},
|
||
},
|
||
|
||
label: {
|
||
default: null,
|
||
parseHTML: (element) => element.getAttribute('data-label'),
|
||
renderHTML: (attributes) => {
|
||
if (!attributes.label) {
|
||
return {};
|
||
}
|
||
|
||
return {
|
||
'data-label': attributes.label,
|
||
};
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
parseHTML() {
|
||
return [
|
||
{
|
||
tag: `span[data-type="${this.name}"]`,
|
||
},
|
||
];
|
||
},
|
||
|
||
renderHTML({ node, HTMLAttributes }) {
|
||
const mergedOptions = { ...this.options };
|
||
|
||
mergedOptions.HTMLAttributes = mergeAttributes(
|
||
{ 'data-type': this.name },
|
||
this.options.HTMLAttributes,
|
||
HTMLAttributes
|
||
);
|
||
const html = this.options.renderHTML({
|
||
options: mergedOptions,
|
||
node,
|
||
});
|
||
|
||
if (typeof html === 'string') {
|
||
return [
|
||
'span',
|
||
mergeAttributes(
|
||
{ 'data-type': this.name },
|
||
this.options.HTMLAttributes,
|
||
HTMLAttributes
|
||
),
|
||
html,
|
||
];
|
||
}
|
||
return html;
|
||
},
|
||
|
||
renderText({ node }) {
|
||
return this.options.renderText({
|
||
options: this.options,
|
||
node,
|
||
});
|
||
},
|
||
|
||
addKeyboardShortcuts() {
|
||
return {
|
||
Backspace: () =>
|
||
this.editor.commands.command(({ tr, state }) => {
|
||
let isMention = false;
|
||
const { selection } = state;
|
||
const { empty, anchor } = selection;
|
||
|
||
if (!empty) {
|
||
return false;
|
||
}
|
||
|
||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||
if (node.type.name === this.name) {
|
||
isMention = true;
|
||
tr.insertText(
|
||
this.options.deleteTriggerWithBackspace
|
||
? ''
|
||
: this.options.suggestion.char || '',
|
||
pos,
|
||
pos + node.nodeSize
|
||
);
|
||
|
||
return false;
|
||
}
|
||
});
|
||
|
||
return isMention;
|
||
}),
|
||
};
|
||
},
|
||
|
||
addProseMirrorPlugins() {
|
||
return [
|
||
Suggestion({
|
||
editor: this.editor,
|
||
...this.options.suggestion,
|
||
}),
|
||
];
|
||
},
|
||
});
|
||
|
||
// Create a lowlight instance with all languages loaded
|
||
const lowlight = createLowlight(all);
|
||
|
||
type EditorSlashMenuProps = {
|
||
items: SuggestionItem[];
|
||
command: (item: SuggestionItem) => void;
|
||
editor: Editor;
|
||
range: Range;
|
||
};
|
||
|
||
const EditorSlashMenu = ({ items, editor, range }: EditorSlashMenuProps) => (
|
||
<Command
|
||
className="border shadow"
|
||
id="slash-command"
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<CommandEmpty className="flex w-full items-center justify-center p-4 text-muted-foreground text-sm">
|
||
<p>No results</p>
|
||
</CommandEmpty>
|
||
<CommandList>
|
||
{items.map((item) => (
|
||
<CommandItem
|
||
className="flex items-center gap-3 pr-3"
|
||
key={item.title}
|
||
onSelect={() => item.command({ editor, range })}
|
||
>
|
||
<div className="flex size-9 shrink-0 items-center justify-center rounded border bg-secondary">
|
||
<item.icon className="text-muted-foreground" size={16} />
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium text-sm">{item.title}</span>
|
||
<span className="text-muted-foreground text-xs">
|
||
{item.description}
|
||
</span>
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandList>
|
||
</Command>
|
||
);
|
||
|
||
const handleCommandNavigation = (event: KeyboardEvent) => {
|
||
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
|
||
const slashCommand = document.querySelector('#slash-command');
|
||
|
||
if (slashCommand) {
|
||
event.preventDefault();
|
||
|
||
slashCommand.dispatchEvent(
|
||
new KeyboardEvent('keydown', {
|
||
key: event.key,
|
||
cancelable: true,
|
||
bubbles: true,
|
||
})
|
||
);
|
||
|
||
return true;
|
||
}
|
||
}
|
||
};
|
||
|
||
export type EditorProviderProps = TiptapEditorProviderProps & {
|
||
className?: string;
|
||
limit?: number;
|
||
placeholder?: string;
|
||
};
|
||
|
||
export const EditorProvider = ({
|
||
className,
|
||
extensions,
|
||
limit,
|
||
placeholder,
|
||
...props
|
||
}: EditorProviderProps) => {
|
||
const defaultExtensions = [
|
||
StarterKit.configure({
|
||
codeBlock: false,
|
||
bulletList: {
|
||
HTMLAttributes: {
|
||
class: cn('list-outside list-disc pl-4'),
|
||
},
|
||
},
|
||
orderedList: {
|
||
HTMLAttributes: {
|
||
class: cn('list-outside list-decimal pl-4'),
|
||
},
|
||
},
|
||
listItem: {
|
||
HTMLAttributes: {
|
||
class: cn('leading-normal'),
|
||
},
|
||
},
|
||
blockquote: {
|
||
HTMLAttributes: {
|
||
class: cn('border-l border-l-2 pl-2'),
|
||
},
|
||
},
|
||
code: {
|
||
HTMLAttributes: {
|
||
class: cn('rounded-md bg-muted px-1.5 py-1 font-medium font-mono'),
|
||
spellcheck: 'false',
|
||
},
|
||
},
|
||
horizontalRule: {
|
||
HTMLAttributes: {
|
||
class: cn('mt-4 mb-6 border-muted-foreground border-t'),
|
||
},
|
||
},
|
||
dropcursor: {
|
||
color: 'var(--border)',
|
||
width: 4,
|
||
},
|
||
}),
|
||
Typography,
|
||
Placeholder.configure({
|
||
placeholder,
|
||
emptyEditorClass:
|
||
'before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none',
|
||
}),
|
||
CharacterCount.configure({
|
||
limit,
|
||
}),
|
||
CodeBlockLowlight.configure({
|
||
lowlight,
|
||
HTMLAttributes: {
|
||
class: cn(
|
||
'rounded-md border p-4 text-sm',
|
||
'bg-background text-foreground',
|
||
'[&_.hljs-doctag]:text-[#d73a49] [&_.hljs-keyword]:text-[#d73a49] [&_.hljs-meta_.hljs-keyword]:text-[#d73a49] [&_.hljs-template-tag]:text-[#d73a49] [&_.hljs-template-variable]:text-[#d73a49] [&_.hljs-type]:text-[#d73a49] [&_.hljs-variable.language]:text-[#d73a49]',
|
||
'[&_.hljs-title.class_.inherited]:text-[#6f42c1] [&_.hljs-title.class]:text-[#6f42c1] [&_.hljs-title.function]:text-[#6f42c1] [&_.hljs-title]:text-[#6f42c1]',
|
||
'[&_.hljs-attr]:text-[#005cc5] [&_.hljs-attribute]:text-[#005cc5] [&_.hljs-literal]:text-[#005cc5] [&_.hljs-meta]:text-[#005cc5] [&_.hljs-number]:text-[#005cc5] [&_.hljs-operator]:text-[#005cc5] [&_.hljs-selector-attr]:text-[#005cc5] [&_.hljs-selector-class]:text-[#005cc5] [&_.hljs-selector-id]:text-[#005cc5] [&_.hljs-variable]:text-[#005cc5]',
|
||
'[&_.hljs-meta_.hljs-string]:text-[#032f62] [&_.hljs-regexp]:text-[#032f62] [&_.hljs-string]:text-[#032f62]',
|
||
'[&_.hljs-built_in]:text-[#e36209] [&_.hljs-symbol]:text-[#e36209]',
|
||
'[&_.hljs-code]:text-[#6a737d] [&_.hljs-comment]:text-[#6a737d] [&_.hljs-formula]:text-[#6a737d]',
|
||
'[&_.hljs-name]:text-[#22863a] [&_.hljs-quote]:text-[#22863a] [&_.hljs-selector-pseudo]:text-[#22863a] [&_.hljs-selector-tag]:text-[#22863a]',
|
||
'[&_.hljs-subst]:text-[#24292e]',
|
||
'[&_.hljs-section]:font-bold [&_.hljs-section]:text-[#005cc5]',
|
||
'[&_.hljs-bullet]:text-[#735c0f]',
|
||
'[&_.hljs-emphasis]:text-[#24292e] [&_.hljs-emphasis]:italic',
|
||
'[&_.hljs-strong]:font-bold [&_.hljs-strong]:text-[#24292e]',
|
||
'[&_.hljs-addition]:bg-[#f0fff4] [&_.hljs-addition]:text-[#22863a]',
|
||
'[&_.hljs-deletion]:bg-[#ffeef0] [&_.hljs-deletion]:text-[#b31d28]'
|
||
),
|
||
},
|
||
}),
|
||
Superscript,
|
||
Subscript,
|
||
Slash.configure({
|
||
suggestion: {
|
||
items: async ({ editor, query }) => {
|
||
const items = await defaultSlashSuggestions({ editor, query });
|
||
|
||
if (!query) {
|
||
return items;
|
||
}
|
||
|
||
const slashFuse = new Fuse(items, {
|
||
keys: ['title', 'description', 'searchTerms'],
|
||
threshold: 0.2,
|
||
minMatchCharLength: 1,
|
||
});
|
||
|
||
const results = slashFuse.search(query);
|
||
|
||
return results.map((result) => result.item);
|
||
},
|
||
char: '/',
|
||
render: () => {
|
||
let component: ReactRenderer<EditorSlashMenuProps>;
|
||
let popup: TippyInstance;
|
||
|
||
return {
|
||
onStart: (onStartProps) => {
|
||
component = new ReactRenderer(EditorSlashMenu, {
|
||
props: onStartProps,
|
||
editor: onStartProps.editor,
|
||
});
|
||
|
||
popup = tippy(document.body, {
|
||
getReferenceClientRect: () =>
|
||
onStartProps.clientRect?.() || new DOMRect(),
|
||
appendTo: () => document.body,
|
||
content: component.element,
|
||
showOnCreate: true,
|
||
interactive: true,
|
||
trigger: 'manual',
|
||
placement: 'bottom-start',
|
||
});
|
||
},
|
||
|
||
onUpdate(onUpdateProps) {
|
||
component.updateProps(onUpdateProps);
|
||
|
||
popup.setProps({
|
||
getReferenceClientRect: () =>
|
||
onUpdateProps.clientRect?.() || new DOMRect(),
|
||
});
|
||
},
|
||
|
||
onKeyDown(onKeyDownProps) {
|
||
if (onKeyDownProps.event.key === 'Escape') {
|
||
popup.hide();
|
||
component.destroy();
|
||
|
||
return true;
|
||
}
|
||
|
||
return handleCommandNavigation(onKeyDownProps.event) ?? false;
|
||
},
|
||
|
||
onExit() {
|
||
popup.destroy();
|
||
component.destroy();
|
||
},
|
||
};
|
||
},
|
||
},
|
||
}),
|
||
Table.configure({
|
||
HTMLAttributes: {
|
||
class: cn(
|
||
'relative m-0 mx-auto my-3 w-full table-fixed border-collapse overflow-hidden rounded-none text-sm'
|
||
),
|
||
},
|
||
allowTableNodeSelection: true,
|
||
}),
|
||
TableRow.configure({
|
||
HTMLAttributes: {
|
||
class: cn(
|
||
'relative box-border min-w-[1em] border p-1 text-start align-top'
|
||
),
|
||
},
|
||
}),
|
||
TableCell.configure({
|
||
HTMLAttributes: {
|
||
class: cn(
|
||
'relative box-border min-w-[1em] border p-1 text-start align-top'
|
||
),
|
||
},
|
||
}),
|
||
TableHeader.configure({
|
||
HTMLAttributes: {
|
||
class: cn(
|
||
'relative box-border min-w-[1em] border bg-secondary p-1 text-start align-top font-medium font-semibold text-muted-foreground'
|
||
),
|
||
},
|
||
}),
|
||
TaskList.configure({
|
||
HTMLAttributes: {
|
||
// 17px = the width of the checkbox + the gap between the checkbox and the text
|
||
class: 'before:translate-x-[17px]',
|
||
},
|
||
}),
|
||
TaskItem.configure({
|
||
HTMLAttributes: {
|
||
class: 'flex items-start gap-1',
|
||
},
|
||
nested: true,
|
||
}),
|
||
TextStyle.configure({ mergeNestedSpanStyles: true }),
|
||
];
|
||
|
||
return (
|
||
<TooltipProvider>
|
||
<div className={cn(className, '[&_.ProseMirror-focused]:outline-none')}>
|
||
<TiptapEditorProvider
|
||
editorProps={{
|
||
handleKeyDown: (_view, event) => {
|
||
handleCommandNavigation(event);
|
||
},
|
||
}}
|
||
extensions={[...defaultExtensions, ...(extensions ?? [])]}
|
||
{...props}
|
||
/>
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
};
|
||
|
||
export type EditorFloatingMenuProps = Omit<FloatingMenuProps, 'editor'>;
|
||
|
||
export const EditorFloatingMenu = ({
|
||
className,
|
||
...props
|
||
}: EditorFloatingMenuProps) => (
|
||
<FloatingMenu
|
||
className={cn('flex items-center bg-secondary', className)}
|
||
editor={null}
|
||
tippyOptions={{
|
||
offset: [32, 0],
|
||
}}
|
||
{...props}
|
||
/>
|
||
);
|
||
|
||
export type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'editor'>;
|
||
|
||
export const EditorBubbleMenu = ({
|
||
className,
|
||
children,
|
||
...props
|
||
}: EditorBubbleMenuProps) => (
|
||
<BubbleMenu
|
||
className={cn(
|
||
'flex rounded-xl border bg-background p-0.5 shadow',
|
||
'[&>*:first-child]:rounded-l-[9px]',
|
||
'[&>*:last-child]:rounded-r-[9px]',
|
||
className
|
||
)}
|
||
editor={null}
|
||
tippyOptions={{
|
||
maxWidth: 'none',
|
||
}}
|
||
{...props}
|
||
>
|
||
{children && Array.isArray(children)
|
||
? children.reduce((acc: ReactNode[], child, index) => {
|
||
if (index === 0) {
|
||
return [child];
|
||
}
|
||
|
||
// biome-ignore lint/suspicious/noArrayIndexKey: "only iterator we have"
|
||
acc.push(<Separator key={index} orientation="vertical" />);
|
||
acc.push(child);
|
||
return acc;
|
||
}, [])
|
||
: children}
|
||
</BubbleMenu>
|
||
);
|
||
|
||
type EditorButtonProps = {
|
||
name: string;
|
||
isActive: () => boolean;
|
||
command: () => void;
|
||
icon: LucideIcon | ((props: LucideProps) => ReactNode);
|
||
hideName?: boolean;
|
||
};
|
||
|
||
const BubbleMenuButton = ({
|
||
name,
|
||
isActive,
|
||
command,
|
||
icon: Icon,
|
||
hideName,
|
||
}: EditorButtonProps) => (
|
||
<Button
|
||
className="flex gap-4"
|
||
onClick={() => command()}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<Icon className="shrink-0 text-muted-foreground" size={12} />
|
||
{!hideName && <span className="flex-1 text-left">{name}</span>}
|
||
{isActive() ? (
|
||
<CheckIcon className="shrink-0 text-muted-foreground" size={12} />
|
||
) : null}
|
||
</Button>
|
||
);
|
||
|
||
export type EditorClearFormattingProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorClearFormatting = ({
|
||
hideName = true,
|
||
}: EditorClearFormattingProps) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||
hideName={hideName}
|
||
icon={RemoveFormattingIcon}
|
||
isActive={() => false}
|
||
name="Clear Formatting"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeTextProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeText = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() =>
|
||
editor.chain().focus().toggleNode('paragraph', 'paragraph').run()
|
||
}
|
||
hideName={hideName}
|
||
// I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
|
||
icon={TextIcon}
|
||
isActive={() =>
|
||
(editor &&
|
||
!editor.isActive('paragraph') &&
|
||
!editor.isActive('bulletList') &&
|
||
!editor.isActive('orderedList')) ??
|
||
false
|
||
}
|
||
name="Text"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeHeading1Props = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeHeading1 = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||
hideName={hideName}
|
||
icon={Heading1Icon}
|
||
isActive={() => editor.isActive('heading', { level: 1 }) ?? false}
|
||
name="Heading 1"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeHeading2Props = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeHeading2 = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
hideName={hideName}
|
||
icon={Heading2Icon}
|
||
isActive={() => editor.isActive('heading', { level: 2 }) ?? false}
|
||
name="Heading 2"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeHeading3Props = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeHeading3 = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||
hideName={hideName}
|
||
icon={Heading3Icon}
|
||
isActive={() => editor.isActive('heading', { level: 3 }) ?? false}
|
||
name="Heading 3"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeBulletListProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeBulletList = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleBulletList().run()}
|
||
hideName={hideName}
|
||
icon={ListIcon}
|
||
isActive={() => editor.isActive('bulletList') ?? false}
|
||
name="Bullet List"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeOrderedListProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeOrderedList = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleOrderedList().run()}
|
||
hideName={hideName}
|
||
icon={ListOrderedIcon}
|
||
isActive={() => editor.isActive('orderedList') ?? false}
|
||
name="Numbered List"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeTaskListProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeTaskList = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleTaskList().run()}
|
||
hideName={hideName}
|
||
icon={CheckSquareIcon}
|
||
isActive={() => editor.isActive('taskItem') ?? false}
|
||
name="To-do List"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeQuoteProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeQuote = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() =>
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.toggleNode('paragraph', 'paragraph')
|
||
.toggleBlockquote()
|
||
.run()
|
||
}
|
||
hideName={hideName}
|
||
icon={TextQuoteIcon}
|
||
isActive={() => editor.isActive('blockquote') ?? false}
|
||
name="Quote"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeCodeProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeCode = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleCodeBlock().run()}
|
||
hideName={hideName}
|
||
icon={CodeIcon}
|
||
isActive={() => editor.isActive('codeBlock') ?? false}
|
||
name="Code"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorNodeTableProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorNodeTable = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() =>
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||
.run()
|
||
}
|
||
hideName={hideName}
|
||
icon={TableIcon}
|
||
isActive={() => editor.isActive('table') ?? false}
|
||
name="Table"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||
open?: boolean;
|
||
onOpenChange?: (open: boolean) => void;
|
||
title: string;
|
||
};
|
||
|
||
export const EditorSelector = ({
|
||
open,
|
||
onOpenChange,
|
||
title,
|
||
className,
|
||
children,
|
||
...props
|
||
}: EditorSelectorProps) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Popover modal onOpenChange={onOpenChange} open={open}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
className="gap-2 rounded-none border-none"
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<span className="whitespace-nowrap text-xs">{title}</span>
|
||
<ChevronDownIcon size={12} />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
className={cn('w-48 p-1', className)}
|
||
sideOffset={5}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatBoldProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatBold = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleBold().run()}
|
||
hideName={hideName}
|
||
icon={BoldIcon}
|
||
isActive={() => editor.isActive('bold') ?? false}
|
||
name="Bold"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatItalicProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatItalic = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleItalic().run()}
|
||
hideName={hideName}
|
||
icon={ItalicIcon}
|
||
isActive={() => editor.isActive('italic') ?? false}
|
||
name="Italic"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatStrikeProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatStrike = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleStrike().run()}
|
||
hideName={hideName}
|
||
icon={StrikethroughIcon}
|
||
isActive={() => editor.isActive('strike') ?? false}
|
||
name="Strikethrough"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatCodeProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatCode = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleCode().run()}
|
||
hideName={hideName}
|
||
icon={CodeIcon}
|
||
isActive={() => editor.isActive('code') ?? false}
|
||
name="Code"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatSubscriptProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatSubscript = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleSubscript().run()}
|
||
hideName={hideName}
|
||
icon={SubscriptIcon}
|
||
isActive={() => editor.isActive('subscript') ?? false}
|
||
name="Subscript"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatSuperscriptProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatSuperscript = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
command={() => editor.chain().focus().toggleSuperscript().run()}
|
||
hideName={hideName}
|
||
icon={SuperscriptIcon}
|
||
isActive={() => editor.isActive('superscript') ?? false}
|
||
name="Superscript"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorFormatUnderlineProps = Pick<EditorButtonProps, 'hideName'>;
|
||
|
||
export const EditorFormatUnderline = ({
|
||
hideName = false,
|
||
}: Pick<EditorButtonProps, 'hideName'>) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<BubbleMenuButton
|
||
// @ts-expect-error "TipTap extensions are not typed"
|
||
command={() => editor.chain().focus().toggleUnderline().run()}
|
||
hideName={hideName}
|
||
icon={UnderlineIcon}
|
||
isActive={() => editor.isActive('underline') ?? false}
|
||
name="Underline"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type EditorLinkSelectorProps = {
|
||
open?: boolean;
|
||
onOpenChange?: (open: boolean) => void;
|
||
};
|
||
|
||
export const EditorLinkSelector = ({
|
||
open,
|
||
onOpenChange,
|
||
}: EditorLinkSelectorProps) => {
|
||
const [url, setUrl] = useState<string>('');
|
||
const inputReference = useRef<HTMLInputElement>(null);
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const isValidUrl = (text: string): boolean => {
|
||
try {
|
||
new URL(text);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const getUrlFromString = (text: string): string | null => {
|
||
if (isValidUrl(text)) {
|
||
return text;
|
||
}
|
||
try {
|
||
if (text.includes('.') && !text.includes(' ')) {
|
||
return new URL(`https://${text}`).toString();
|
||
}
|
||
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
inputReference.current?.focus();
|
||
}, []);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
||
event.preventDefault();
|
||
|
||
const href = getUrlFromString(url);
|
||
|
||
if (href) {
|
||
// @ts-expect-error "TipTap extensions are not typed"
|
||
editor.chain().focus().setLink({ href }).run();
|
||
onOpenChange?.(false);
|
||
}
|
||
};
|
||
|
||
const defaultValue = (editor.getAttributes('link') as { href?: string }).href;
|
||
|
||
return (
|
||
<Popover modal onOpenChange={onOpenChange} open={open}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
className="gap-2 rounded-none border-none"
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<ExternalLinkIcon size={12} />
|
||
<p
|
||
className={cn(
|
||
'text-xs underline decoration-text-muted underline-offset-4',
|
||
{
|
||
'text-primary': editor.isActive('link'),
|
||
}
|
||
)}
|
||
>
|
||
Link
|
||
</p>
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
|
||
<form className="flex p-1" onSubmit={handleSubmit}>
|
||
<input
|
||
aria-label="Link URL"
|
||
className="flex-1 bg-background p-1 text-sm outline-none"
|
||
defaultValue={defaultValue ?? ''}
|
||
onChange={(event) => setUrl(event.target.value)}
|
||
placeholder="Paste a link"
|
||
ref={inputReference}
|
||
type="text"
|
||
value={url}
|
||
/>
|
||
{editor.getAttributes('link').href ? (
|
||
<Button
|
||
className="flex h-8 items-center rounded-sm p-1 text-destructive transition-all hover:bg-destructive-foreground dark:hover:bg-destructive"
|
||
onClick={() => {
|
||
// @ts-expect-error "TipTap extensions are not typed"
|
||
editor.chain().focus().unsetLink().run();
|
||
onOpenChange?.(false);
|
||
}}
|
||
size="icon"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<TrashIcon size={12} />
|
||
</Button>
|
||
) : (
|
||
<Button className="h-8" size="icon" variant="secondary">
|
||
<CheckIcon size={12} />
|
||
</Button>
|
||
)}
|
||
</form>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
};
|
||
|
||
export type EditorTableMenuProps = {
|
||
children: ReactNode;
|
||
};
|
||
|
||
export const EditorTableMenu = ({ children }: EditorTableMenuProps) => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
const isActive = editor.isActive('table');
|
||
|
||
return (
|
||
<div
|
||
className={cn({
|
||
hidden: !isActive,
|
||
})}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export type EditorTableGlobalMenuProps = {
|
||
children: ReactNode;
|
||
};
|
||
|
||
export const EditorTableGlobalMenu = ({
|
||
children,
|
||
}: EditorTableGlobalMenuProps) => {
|
||
const { editor } = useCurrentEditor();
|
||
const [top, setTop] = useState(0);
|
||
const [left, setLeft] = useState(0);
|
||
|
||
useEffect(() => {
|
||
if (!editor) {
|
||
return;
|
||
}
|
||
|
||
editor.on('selectionUpdate', () => {
|
||
const selection = window.getSelection();
|
||
|
||
if (!selection) {
|
||
return;
|
||
}
|
||
|
||
const range = selection.getRangeAt(0);
|
||
let startContainer = range.startContainer as HTMLElement | string;
|
||
|
||
if (!(startContainer instanceof HTMLElement)) {
|
||
startContainer = range.startContainer.parentElement as HTMLElement;
|
||
}
|
||
|
||
const tableNode = startContainer.closest('table');
|
||
|
||
if (!tableNode) {
|
||
return;
|
||
}
|
||
|
||
const tableRect = tableNode.getBoundingClientRect();
|
||
|
||
setTop(tableRect.top + tableRect.height);
|
||
setLeft(tableRect.left + tableRect.width / 2);
|
||
});
|
||
|
||
return () => {
|
||
editor.off('selectionUpdate');
|
||
};
|
||
}, [editor]);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'-translate-x-1/2 absolute flex translate-y-1/2 items-center rounded-full border bg-background shadow-xl',
|
||
{
|
||
hidden: !(left || top),
|
||
}
|
||
)}
|
||
style={{ top, left }}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export type EditorTableColumnMenuProps = {
|
||
children: ReactNode;
|
||
};
|
||
|
||
export const EditorTableColumnMenu = ({
|
||
children,
|
||
}: EditorTableColumnMenuProps) => {
|
||
const { editor } = useCurrentEditor();
|
||
const [top, setTop] = useState(0);
|
||
const [left, setLeft] = useState(0);
|
||
|
||
useEffect(() => {
|
||
if (!editor) {
|
||
return;
|
||
}
|
||
|
||
editor.on('selectionUpdate', () => {
|
||
const selection = window.getSelection();
|
||
|
||
if (!selection) {
|
||
return;
|
||
}
|
||
|
||
const range = selection.getRangeAt(0);
|
||
let startContainer = range.startContainer as HTMLElement | string;
|
||
|
||
if (!(startContainer instanceof HTMLElement)) {
|
||
startContainer = range.startContainer.parentElement as HTMLElement;
|
||
}
|
||
|
||
// Get the closest table cell (td or th)
|
||
const tableCell = startContainer.closest('td, th');
|
||
|
||
if (!tableCell) {
|
||
return;
|
||
}
|
||
|
||
const cellRect = tableCell.getBoundingClientRect();
|
||
|
||
setTop(cellRect.top);
|
||
setLeft(cellRect.left + cellRect.width / 2);
|
||
});
|
||
|
||
return () => {
|
||
editor.off('selectionUpdate');
|
||
};
|
||
}, [editor]);
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
asChild
|
||
className={cn(
|
||
'-translate-x-1/2 -translate-y-1/2 absolute flex h-4 w-7 overflow-hidden rounded-md border bg-background shadow-xl',
|
||
{
|
||
hidden: !(left || top),
|
||
}
|
||
)}
|
||
style={{ top, left }}
|
||
>
|
||
<Button size="icon" variant="ghost">
|
||
<EllipsisIcon className="text-muted-foreground" size={16} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent>{children}</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
};
|
||
|
||
export type EditorTableRowMenuProps = {
|
||
children: ReactNode;
|
||
};
|
||
|
||
export const EditorTableRowMenu = ({ children }: EditorTableRowMenuProps) => {
|
||
const { editor } = useCurrentEditor();
|
||
const [top, setTop] = useState(0);
|
||
const [left, setLeft] = useState(0);
|
||
|
||
useEffect(() => {
|
||
if (!editor) {
|
||
return;
|
||
}
|
||
|
||
editor.on('selectionUpdate', () => {
|
||
const selection = window.getSelection();
|
||
|
||
if (!selection) {
|
||
return;
|
||
}
|
||
|
||
const range = selection.getRangeAt(0);
|
||
let startContainer = range.startContainer as HTMLElement | string;
|
||
|
||
if (!(startContainer instanceof HTMLElement)) {
|
||
startContainer = range.startContainer.parentElement as HTMLElement;
|
||
}
|
||
|
||
const tableRow = startContainer.closest('tr');
|
||
|
||
if (!tableRow) {
|
||
return;
|
||
}
|
||
|
||
const rowRect = tableRow.getBoundingClientRect();
|
||
|
||
setTop(rowRect.top + rowRect.height / 2);
|
||
setLeft(rowRect.left);
|
||
});
|
||
|
||
return () => {
|
||
editor.off('selectionUpdate');
|
||
};
|
||
}, [editor]);
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
'-translate-x-1/2 -translate-y-1/2 absolute flex h-7 w-4 overflow-hidden rounded-md border bg-background shadow-xl',
|
||
{
|
||
hidden: !(left || top),
|
||
}
|
||
)}
|
||
size="icon"
|
||
style={{ top, left }}
|
||
variant="ghost"
|
||
>
|
||
<EllipsisVerticalIcon className="text-muted-foreground" size={12} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent>{children}</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
};
|
||
|
||
export const EditorTableColumnBefore = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().addColumnBefore().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
|
||
<ArrowLeftIcon className="text-muted-foreground" size={16} />
|
||
<span>Add column before</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export const EditorTableColumnAfter = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().addColumnAfter().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
|
||
<ArrowRightIcon className="text-muted-foreground" size={16} />
|
||
<span>Add column after</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export const EditorTableRowBefore = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().addRowBefore().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
|
||
<ArrowUpIcon className="text-muted-foreground" size={16} />
|
||
<span>Add row before</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export const EditorTableRowAfter = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().addRowAfter().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
|
||
<ArrowDownIcon className="text-muted-foreground" size={16} />
|
||
<span>Add row after</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export const EditorTableColumnDelete = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().deleteColumn().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
|
||
<TrashIcon className="text-destructive" size={16} />
|
||
<span>Delete column</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export const EditorTableRowDelete = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().deleteRow().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
|
||
<TrashIcon className="text-destructive" size={16} />
|
||
<span>Delete row</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
};
|
||
|
||
export const EditorTableHeaderColumnToggle = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().toggleHeaderColumn().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="flex items-center gap-2 rounded-full"
|
||
onClick={handleClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ColumnsIcon className="text-muted-foreground" size={16} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<span>Toggle header column</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export const EditorTableHeaderRowToggle = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().toggleHeaderRow().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="flex items-center gap-2 rounded-full"
|
||
onClick={handleClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<RowsIcon className="text-muted-foreground" size={16} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<span>Toggle header row</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export const EditorTableDelete = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().deleteTable().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="flex items-center gap-2 rounded-full"
|
||
onClick={handleClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<TrashIcon className="text-destructive" size={16} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<span>Delete table</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export const EditorTableMergeCells = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().mergeCells().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="flex items-center gap-2 rounded-full"
|
||
onClick={handleClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<TableCellsMergeIcon className="text-muted-foreground" size={16} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<span>Merge cells</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export const EditorTableSplitCell = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().splitCell().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="flex items-center gap-2 rounded-full"
|
||
onClick={handleClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<TableColumnsSplitIcon className="text-muted-foreground" size={16} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<span>Split cell</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export const EditorTableFix = () => {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (editor) {
|
||
editor.chain().focus().fixTables().run();
|
||
}
|
||
}, [editor]);
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="flex items-center gap-2 rounded-full"
|
||
onClick={handleClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<BoltIcon className="text-muted-foreground" size={16} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<span>Fix table</span>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export type EditorCharacterCountProps = {
|
||
children: ReactNode;
|
||
className?: string;
|
||
};
|
||
|
||
export const EditorCharacterCount = {
|
||
Characters({ children, className }: EditorCharacterCountProps) {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow',
|
||
className
|
||
)}
|
||
>
|
||
{children}
|
||
{editor.storage.characterCount.characters()}
|
||
</div>
|
||
);
|
||
},
|
||
|
||
Words({ children, className }: EditorCharacterCountProps) {
|
||
const { editor } = useCurrentEditor();
|
||
|
||
if (!editor) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow',
|
||
className
|
||
)}
|
||
>
|
||
{children}
|
||
{editor.storage.characterCount.words()}
|
||
</div>
|
||
);
|
||
},
|
||
};
|