Add additional toolbar buttons

This commit is contained in:
Nate Kelley 2025-07-29 16:07:24 -06:00
parent d46688bc5b
commit 04dba563bd
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
16 changed files with 1666 additions and 11 deletions

View File

@ -0,0 +1,136 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { buttonVariants } from '@/components/ui/buttons';
import { cn } from '@/lib/utils';
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
data-slot="alert-dialog-overlay"
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
'bg-background data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
data-slot="alert-dialog-content"
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
data-slot="alert-dialog-header"
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
data-slot="alert-dialog-footer"
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
className={cn('text-lg font-semibold', className)}
data-slot="alert-dialog-title"
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
data-slot="alert-dialog-description"
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outlined' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger
};

View File

@ -0,0 +1 @@
export * from './AlertDialogBase';

View File

@ -6,6 +6,7 @@ import { Editor } from './Editor';
import { useReportEditor } from './useReportEditor';
import { useMemoizedFn } from '@/hooks';
import { ReportElements } from '@buster/server-shared/reports';
import { cn } from '@/lib/utils';
interface ReportEditorProps {
// We accept the generic Value type but recommend using ReportTypes.Value for type safety
@ -56,7 +57,7 @@ export const ReportEditor = React.memo(
variant={variant}
readonly={readOnly}
disabled={disabled}
className={className}>
className={cn('pb-[20vh]', className)}>
<Editor style={style} placeholder={placeholder} disabled={disabled} />
</EditorContainer>
</Plate>

View File

@ -0,0 +1,84 @@
'use client';
import * as React from 'react';
import type { Alignment } from '@platejs/basic-styles';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import { TextAlignPlugin } from '@platejs/basic-styles/react';
import {
TextAlignCenter,
TextAlignJustify,
TextAlignLeft,
TextAlignRight
} from '@/components/ui/icons';
import { useEditorPlugin, useSelectionFragmentProp } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { ToolbarButton } from './Toolbar';
const items = [
{
icon: TextAlignLeft,
value: 'left'
},
{
icon: TextAlignCenter,
value: 'center'
},
{
icon: TextAlignRight,
value: 'right'
},
{
icon: TextAlignJustify,
value: 'justify'
}
];
export function AlignToolbarButton(props: DropdownMenuProps) {
const { editor, tf } = useEditorPlugin(TextAlignPlugin);
const value =
useSelectionFragmentProp({
defaultValue: 'start',
getProp: (node) => node.align
}) ?? 'left';
const [open, setOpen] = React.useState(false);
const IconValue = items.find((item) => item.value === value)?.icon ?? TextAlignLeft;
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Align" isDropdown>
<IconValue />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-0" align="start">
<DropdownMenuRadioGroup
value={value}
onValueChange={(value) => {
tf.textAlign.setNodes(value as Alignment);
editor.tf.focus();
}}>
{items.map(({ icon: Icon, value: itemValue }) => (
<DropdownMenuRadioItem
key={itemValue}
className="data-[state=checked]:bg-accent pl-2 *:first:[span]:hidden"
value={itemValue}>
<Icon />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -31,7 +31,7 @@ import { BasicMarksKit } from '../plugins/basic-marks-kit';
import { type TDiscussion, discussionPlugin } from '../plugins/discussion-kit';
import { EditorContainer } from '../EditorContainer';
import { EditorContent as Editor } from '../Editor';
import { Editor } from '../Editor';
export interface TComment {
id: string;

View File

@ -8,7 +8,11 @@ import {
TextItalic,
TextStrikethrough,
TextUnderline,
WandSparkle
WandSparkle,
ArrowUpToLine,
TextColor2,
BucketPaint,
TextHighlight2
} from '@/components/ui/icons';
import { KEYS } from 'platejs';
import { useEditorReadOnly } from 'platejs/react';
@ -23,6 +27,24 @@ import { SuggestionToolbarButton } from './SuggestionToolbarButton';
import { ToolbarGroup } from './Toolbar';
import { TurnIntoToolbarButton } from './TurnIntoToolbarButton';
import { UndoToolbarButton, RedoToolbarButton } from './UndoToolbarButton';
import { ExportToolbarButton } from './ExportToolbarButton';
import { ImportToolbarButton } from './ImportToolbarButton';
import { InsertToolbarButton } from './InsertToolbarButton';
import { FontSizeToolbarButton } from './FontSizeToolbarButton';
import { FontColorToolbarButton } from './FontColorToolbarButton';
import { AlignToolbarButton } from './AlignToolbarButton';
import {
BulletedListToolbarButton,
NumberedListToolbarButton,
TodoListToolbarButton
} from './ListToolbarButton';
import { ToggleToolbarButton } from './ToggleToolbarButton';
import { TableToolbarButton } from './TableToolbarButton';
import { EmojiToolbarButton } from './EmojiToolbarButton';
import { MediaToolbarButton } from './MediaToolbarButton';
import { LineHeightToolbarButton } from './LineHeightToolbarButton';
import { IndentToolbarButton, OutdentToolbarButton } from './IndentToolbarButton';
import { ModeToolbarButton } from './ModeToolbarButton';
export function FixedToolbarButtons() {
const readOnly = useEditorReadOnly();
@ -58,31 +80,31 @@ export function FixedToolbarButtons() {
<ToolbarGroup>
<MarkToolbarButton nodeType={KEYS.bold} tooltip="Bold (⌘+B)">
<BoldIcon />
<TextBold />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic} tooltip="Italic (⌘+I)">
<ItalicIcon />
<TextItalic />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.underline} tooltip="Underline (⌘+U)">
<UnderlineIcon />
<TextUnderline />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.strikethrough} tooltip="Strikethrough (⌘+⇧+M)">
<StrikethroughIcon />
<TextStrikethrough />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code} tooltip="Code (⌘+E)">
<Code2Icon />
<Code2 />
</MarkToolbarButton>
<FontColorToolbarButton nodeType={KEYS.color} tooltip="Text color">
<BaselineIcon />
<TextColor2 />
</FontColorToolbarButton>
<FontColorToolbarButton nodeType={KEYS.backgroundColor} tooltip="Background color">
<PaintBucketIcon />
<BucketPaint />
</FontColorToolbarButton>
</ToolbarGroup>
@ -124,7 +146,7 @@ export function FixedToolbarButtons() {
<ToolbarGroup>
<MarkToolbarButton nodeType={KEYS.highlight} tooltip="Highlight">
<HighlighterIcon />
<TextHighlight2 />
</MarkToolbarButton>
<CommentToolbarButton />
</ToolbarGroup>

View File

@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import type { TElement } from 'platejs';
import { toUnitLess } from '@platejs/basic-styles';
import { FontSizePlugin } from '@platejs/basic-styles/react';
import { Minus, Plus } from '@/components/ui/icons';
import { KEYS } from 'platejs';
import { useEditorPlugin, useEditorSelector } from 'platejs/react';
import { PopoverBase, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { ToolbarButton } from './Toolbar';
const DEFAULT_FONT_SIZE = '16';
const FONT_SIZE_MAP = {
h1: '36',
h2: '24',
h3: '20'
} as const;
const FONT_SIZES = [
'8',
'9',
'10',
'12',
'14',
'16',
'18',
'24',
'30',
'36',
'48',
'60',
'72',
'96'
] as const;
export function FontSizeToolbarButton() {
const [inputValue, setInputValue] = React.useState(DEFAULT_FONT_SIZE);
const [isFocused, setIsFocused] = React.useState(false);
const { editor, tf } = useEditorPlugin(FontSizePlugin);
const cursorFontSize = useEditorSelector((editor) => {
const fontSize = editor.api.marks()?.[KEYS.fontSize];
if (fontSize) {
return toUnitLess(fontSize as string);
}
const [block] = editor.api.block<TElement>() || [];
if (!block?.type) return DEFAULT_FONT_SIZE;
return block.type in FONT_SIZE_MAP
? FONT_SIZE_MAP[block.type as keyof typeof FONT_SIZE_MAP]
: DEFAULT_FONT_SIZE;
}, []);
const handleInputChange = () => {
const newSize = toUnitLess(inputValue);
if (Number.parseInt(newSize) < 1 || Number.parseInt(newSize) > 100) {
editor.tf.focus();
return;
}
if (newSize !== toUnitLess(cursorFontSize)) {
tf.fontSize.addMark(`${newSize}px`);
}
editor.tf.focus();
};
const handleFontSizeChange = (delta: number) => {
const newSize = Number(displayValue) + delta;
tf.fontSize.addMark(`${newSize}px`);
editor.tf.focus();
};
const displayValue = isFocused ? inputValue : cursorFontSize;
return (
<div className="bg-muted/60 flex h-7 items-center gap-1 rounded-md p-0">
<ToolbarButton onClick={() => handleFontSizeChange(-1)}>
<Minus />
</ToolbarButton>
<PopoverBase open={isFocused} modal={false}>
<PopoverTrigger asChild>
<input
className={cn(
'hover:bg-muted h-full w-10 shrink-0 bg-transparent px-1 text-center text-sm'
)}
value={displayValue}
onBlur={() => {
setIsFocused(false);
handleInputChange();
}}
onChange={(e) => setInputValue(e.target.value)}
onFocus={() => {
setIsFocused(true);
setInputValue(toUnitLess(cursorFontSize));
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleInputChange();
}
}}
data-plate-focus="true"
type="text"
/>
</PopoverTrigger>
<PopoverContent className="w-10 px-px py-1" onOpenAutoFocus={(e) => e.preventDefault()}>
{FONT_SIZES.map((size) => (
<button
key={size}
className={cn(
'hover:bg-accent data-[highlighted=true]:bg-accent flex h-8 w-full items-center justify-center text-sm'
)}
onClick={() => {
tf.fontSize.addMark(`${size}px`);
setIsFocused(false);
}}
data-highlighted={size === displayValue}
type="button">
{size}
</button>
))}
</PopoverContent>
</PopoverBase>
<ToolbarButton onClick={() => handleFontSizeChange(1)}>
<Plus />
</ToolbarButton>
</div>
);
}

View File

@ -0,0 +1,99 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import { MarkdownPlugin } from '@platejs/markdown';
import { ArrowUpToLine } from '@/components/ui/icons';
import { getEditorDOMFromHtmlString } from 'platejs';
import { useEditorRef } from 'platejs/react';
import { useFilePicker } from 'use-file-picker';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { ToolbarButton } from './Toolbar';
type ImportType = 'html' | 'markdown';
export function ImportToolbarButton(props: DropdownMenuProps) {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
const getFileNodes = (text: string, type: ImportType) => {
if (type === 'html') {
const editorNode = getEditorDOMFromHtmlString(text);
const nodes = editor.api.html.deserialize({
element: editorNode
});
return nodes;
}
if (type === 'markdown') {
return editor.getApi(MarkdownPlugin).markdown.deserialize(text);
}
return [];
};
const { openFilePicker: openMdFilePicker } = useFilePicker({
accept: ['.md', '.mdx'],
multiple: false,
onFilesSelected: async ({ plainFiles }) => {
const text = await plainFiles[0].text();
const nodes = getFileNodes(text, 'markdown');
editor.tf.insertNodes(nodes);
}
});
const { openFilePicker: openHtmlFilePicker } = useFilePicker({
accept: ['text/html'],
multiple: false,
onFilesSelected: async ({ plainFiles }) => {
const text = await plainFiles[0].text();
const nodes = getFileNodes(text, 'html');
editor.tf.insertNodes(nodes);
}
});
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Import" isDropdown>
<div className="size-4">
<ArrowUpToLine />
</div>
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => {
openHtmlFilePicker();
}}>
Import from HTML
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
openMdFilePicker();
}}>
Import from Markdown
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { useIndentButton, useOutdentButton } from '@platejs/indent/react';
import { IndentIncrease, IndentDecrease } from '@/components/ui/icons';
import { ToolbarButton } from './Toolbar';
export function IndentToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {
const { props: buttonProps } = useIndentButton();
return (
<ToolbarButton {...props} {...buttonProps} tooltip="Indent">
<IndentIncrease />
</ToolbarButton>
);
}
export function OutdentToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {
const { props: buttonProps } = useOutdentButton();
return (
<ToolbarButton {...props} {...buttonProps} tooltip="Outdent">
<IndentDecrease />
</ToolbarButton>
);
}

View File

@ -0,0 +1,246 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import {
Calendar,
ChevronRight,
GridLayoutCols3,
FileCloud,
Film,
Heading1,
Heading2,
Heading3,
Image,
Link2,
UnorderedList,
OrderedList,
Minus,
Pilcrow,
Plus,
Quote,
Equation,
ShapeSquare,
Table,
Book2,
Code2
} from '@/components/ui/icons';
import { KEYS } from 'platejs';
import { type PlateEditor, useEditorRef } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { insertBlock, insertInlineElement } from './transforms';
import { ToolbarButton, ToolbarMenuGroup } from './Toolbar';
type Group = {
group: string;
items: Item[];
};
interface Item {
icon: React.ReactNode;
value: string;
onSelect: (editor: PlateEditor, value: string) => void;
focusEditor?: boolean;
label?: string;
}
const groups: Group[] = [
{
group: 'Basic blocks',
items: [
{
icon: <Pilcrow />,
label: 'Paragraph',
value: KEYS.p
},
{
icon: <Heading1 />,
label: 'Heading 1',
value: 'h1'
},
{
icon: <Heading2 />,
label: 'Heading 2',
value: 'h2'
},
{
icon: <Heading3 />,
label: 'Heading 3',
value: 'h3'
},
{
icon: <Table />,
label: 'Table',
value: KEYS.table
},
{
icon: <Code2 />,
label: 'Code',
value: KEYS.codeBlock
},
{
icon: <Quote />,
label: 'Quote',
value: KEYS.blockquote
},
{
icon: <Minus />,
label: 'Divider',
value: KEYS.hr
}
].map((item) => ({
...item,
onSelect: (editor, value) => {
insertBlock(editor, value);
}
}))
},
{
group: 'Lists',
items: [
{
icon: <UnorderedList />,
label: 'Bulleted list',
value: KEYS.ul
},
{
icon: <OrderedList />,
label: 'Numbered list',
value: KEYS.ol
},
{
icon: <ShapeSquare />,
label: 'To-do list',
value: KEYS.listTodo
},
{
icon: <ChevronRight />,
label: 'Toggle list',
value: KEYS.toggle
}
].map((item) => ({
...item,
onSelect: (editor, value) => {
insertBlock(editor, value);
}
}))
},
{
group: 'Media',
items: [
{
icon: <Image />,
label: 'Image',
value: KEYS.img
},
{
icon: <Film />,
label: 'Embed',
value: KEYS.mediaEmbed
}
].map((item) => ({
...item,
onSelect: (editor, value) => {
insertBlock(editor, value);
}
}))
},
{
group: 'Advanced blocks',
items: [
{
icon: <Book2 />,
label: 'Table of contents',
value: KEYS.toc
},
{
icon: <GridLayoutCols3 />,
label: '3 columns',
value: 'action_three_columns'
},
{
focusEditor: false,
icon: <Equation />,
label: 'Equation',
value: KEYS.equation
}
].map((item) => ({
...item,
onSelect: (editor, value) => {
insertBlock(editor, value);
}
}))
},
{
group: 'Inline',
items: [
{
icon: <Link2 />,
label: 'Link',
value: KEYS.link
},
{
focusEditor: true,
icon: <Calendar />,
label: 'Date',
value: KEYS.date
},
{
focusEditor: false,
icon: <Equation />,
label: 'Inline Equation',
value: KEYS.inlineEquation
}
].map((item) => ({
...item,
onSelect: (editor, value) => {
insertInlineElement(editor, value);
}
}))
}
];
export function InsertToolbarButton(props: DropdownMenuProps) {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Insert" isDropdown>
<Plus />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="flex max-h-[500px] min-w-0 flex-col overflow-y-auto"
align="start">
{groups.map(({ group, items: nestedItems }) => (
<ToolbarMenuGroup key={group} label={group}>
{nestedItems.map(({ icon, label, value, onSelect }) => (
<DropdownMenuItem
key={value}
className="min-w-[180px]"
onSelect={() => {
onSelect(editor, value);
editor.tf.focus();
}}>
{icon}
{label}
</DropdownMenuItem>
))}
</ToolbarMenuGroup>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,68 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import { LineHeightPlugin } from '@platejs/basic-styles/react';
import { DropdownMenuItemIndicator } from '@radix-ui/react-dropdown-menu';
import { Check, TextTool2 } from '@/components/ui/icons';
import { useEditorRef, useSelectionFragmentProp } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { ToolbarButton } from './Toolbar';
export function LineHeightToolbarButton(props: DropdownMenuProps) {
const editor = useEditorRef();
const { defaultNodeValue, validNodeValues: values = [] } =
editor.getInjectProps(LineHeightPlugin);
const value = useSelectionFragmentProp({
defaultValue: defaultNodeValue,
getProp: (node) => node.lineHeight
});
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Line height" isDropdown>
<div className="size-4">
<TextTool2 />
</div>
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-0" align="start">
<DropdownMenuRadioGroup
value={value}
onValueChange={(newValue) => {
editor.getTransforms(LineHeightPlugin).lineHeight.setNodes(Number(newValue));
editor.tf.focus();
}}>
{values.map((value) => (
<DropdownMenuRadioItem
key={value}
className="min-w-[180px] pl-2 *:first:[span]:hidden"
value={value}>
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check />
</DropdownMenuItemIndicator>
</span>
{value}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,192 @@
'use client';
import * as React from 'react';
import { ListStyleType, someList, toggleList } from '@platejs/list';
import { useIndentTodoToolBarButton, useIndentTodoToolBarButtonState } from '@platejs/list/react';
import { UnorderedList, OrderedList, ListTodo } from '@/components/ui/icons';
import { useEditorRef, useEditorSelector } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
ToolbarButton,
ToolbarSplitButton,
ToolbarSplitButtonPrimary,
ToolbarSplitButtonSecondary
} from './Toolbar';
export function BulletedListToolbarButton() {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
const pressed = useEditorSelector(
(editor) => someList(editor, [ListStyleType.Disc, ListStyleType.Circle, ListStyleType.Square]),
[]
);
return (
<ToolbarSplitButton pressed={open}>
<ToolbarSplitButtonPrimary
className="data-[state=on]:bg-accent data-[state=on]:text-accent-foreground"
onClick={() => {
toggleList(editor, {
listStyleType: ListStyleType.Disc
});
}}
data-state={pressed ? 'on' : 'off'}>
<div className="size-4">
<UnorderedList />
</div>
</ToolbarSplitButtonPrimary>
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<ToolbarSplitButtonSecondary />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" alignOffset={-32}>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
toggleList(editor, {
listStyleType: ListStyleType.Disc
})
}>
<div className="flex items-center gap-2">
<div className="size-2 rounded-full border border-current bg-current" />
Default
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toggleList(editor, {
listStyleType: ListStyleType.Circle
})
}>
<div className="flex items-center gap-2">
<div className="size-2 rounded-full border border-current" />
Circle
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toggleList(editor, {
listStyleType: ListStyleType.Square
})
}>
<div className="flex items-center gap-2">
<div className="size-2 border border-current bg-current" />
Square
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ToolbarSplitButton>
);
}
export function NumberedListToolbarButton() {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
const pressed = useEditorSelector(
(editor) =>
someList(editor, [
ListStyleType.Decimal,
ListStyleType.LowerAlpha,
ListStyleType.UpperAlpha,
ListStyleType.LowerRoman,
ListStyleType.UpperRoman
]),
[]
);
return (
<ToolbarSplitButton pressed={open}>
<ToolbarSplitButtonPrimary
className="data-[state=on]:bg-accent data-[state=on]:text-accent-foreground"
onClick={() =>
toggleList(editor, {
listStyleType: ListStyleType.Decimal
})
}
data-state={pressed ? 'on' : 'off'}>
<div className="size-4">
<OrderedList />
</div>
</ToolbarSplitButtonPrimary>
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<ToolbarSplitButtonSecondary />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" alignOffset={-32}>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() =>
toggleList(editor, {
listStyleType: ListStyleType.Decimal
})
}>
Decimal (1, 2, 3)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleList(editor, {
listStyleType: ListStyleType.LowerAlpha
})
}>
Lower Alpha (a, b, c)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleList(editor, {
listStyleType: ListStyleType.UpperAlpha
})
}>
Upper Alpha (A, B, C)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleList(editor, {
listStyleType: ListStyleType.LowerRoman
})
}>
Lower Roman (i, ii, iii)
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleList(editor, {
listStyleType: ListStyleType.UpperRoman
})
}>
Upper Roman (I, II, III)
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ToolbarSplitButton>
);
}
export function TodoListToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {
const state = useIndentTodoToolBarButtonState({ nodeType: 'todo' });
const { props: buttonProps } = useIndentTodoToolBarButton(state);
return (
<ToolbarButton {...props} {...buttonProps} tooltip="Todo">
<div className="size-4">
<ListTodo />
</div>
</ToolbarButton>
);
}

View File

@ -0,0 +1,224 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import { PlaceholderPlugin } from '@platejs/media/react';
import { VolumeUp, FileCloud, Film, Image, Link } from '@/components/ui/icons';
import { isUrl, KEYS } from 'platejs';
import { useEditorRef } from 'platejs/react';
import { toast } from 'sonner';
import { useFilePicker } from 'use-file-picker';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/inputs';
import {
ToolbarSplitButton,
ToolbarSplitButtonPrimary,
ToolbarSplitButtonSecondary
} from './Toolbar';
const MEDIA_CONFIG: Record<
string,
{
accept: string[];
icon: React.ReactNode;
title: string;
tooltip: string;
}
> = {
[KEYS.audio]: {
accept: ['audio/*'],
icon: (
<div className="size-4">
<VolumeUp />
</div>
),
title: 'Insert Audio',
tooltip: 'Audio'
},
[KEYS.file]: {
accept: ['*'],
icon: (
<div className="size-4">
<FileCloud />
</div>
),
title: 'Insert File',
tooltip: 'File'
},
[KEYS.img]: {
accept: ['image/*'],
icon: (
<div className="size-4">
<Image />
</div>
),
title: 'Insert Image',
tooltip: 'Image'
},
[KEYS.video]: {
accept: ['video/*'],
icon: (
<div className="size-4">
<Film />
</div>
),
title: 'Insert Video',
tooltip: 'Video'
}
};
export function MediaToolbarButton({
nodeType,
...props
}: DropdownMenuProps & { nodeType: string }) {
const currentConfig = MEDIA_CONFIG[nodeType];
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
const [dialogOpen, setDialogOpen] = React.useState(false);
const { openFilePicker } = useFilePicker({
accept: currentConfig.accept,
multiple: true,
onFilesSelected: ({ plainFiles: updatedFiles }) => {
editor.getTransforms(PlaceholderPlugin).insert.media(updatedFiles);
}
});
return (
<>
<ToolbarSplitButton
onClick={() => {
openFilePicker();
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
}
}}
pressed={open}>
<ToolbarSplitButtonPrimary>{currentConfig.icon}</ToolbarSplitButtonPrimary>
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarSplitButtonSecondary />
</DropdownMenuTrigger>
<DropdownMenuContent onClick={(e) => e.stopPropagation()} align="start" alignOffset={-32}>
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => openFilePicker()}>
{currentConfig.icon}
Upload from computer
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<div className="size-4">
<Link />
</div>
Insert via URL
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ToolbarSplitButton>
<AlertDialog
open={dialogOpen}
onOpenChange={(value) => {
setDialogOpen(value);
}}>
<AlertDialogContent className="gap-6">
<MediaUrlDialogContent
currentConfig={currentConfig}
nodeType={nodeType}
setOpen={setDialogOpen}
/>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function MediaUrlDialogContent({
currentConfig,
nodeType,
setOpen
}: {
currentConfig: (typeof MEDIA_CONFIG)[string];
nodeType: string;
setOpen: (value: boolean) => void;
}) {
const editor = useEditorRef();
const [url, setUrl] = React.useState('');
const embedMedia = React.useCallback(() => {
if (!isUrl(url)) return toast.error('Invalid URL');
setOpen(false);
editor.tf.insertNodes({
children: [{ text: '' }],
name: nodeType === KEYS.file ? url.split('/').pop() : undefined,
type: nodeType,
url
});
}, [url, editor, nodeType, setOpen]);
return (
<>
<AlertDialogHeader>
<AlertDialogTitle>{currentConfig.title}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription className="group relative w-full">
<label
className="text-muted-foreground/70 group-focus-within:text-foreground has-[+input:not(:placeholder-shown)]:text-foreground absolute top-1/2 block -translate-y-1/2 cursor-text px-1 text-sm transition-all group-focus-within:pointer-events-none group-focus-within:top-0 group-focus-within:cursor-default group-focus-within:text-xs group-focus-within:font-medium has-[+input:not(:placeholder-shown)]:pointer-events-none has-[+input:not(:placeholder-shown)]:top-0 has-[+input:not(:placeholder-shown)]:cursor-default has-[+input:not(:placeholder-shown)]:text-xs has-[+input:not(:placeholder-shown)]:font-medium"
htmlFor="url">
<span className="bg-background inline-flex px-2">URL</span>
</label>
<Input
id="url"
className="w-full"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') embedMedia();
}}
placeholder=""
type="url"
autoFocus
/>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
embedMedia();
}}>
Accept
</AlertDialogAction>
</AlertDialogFooter>
</>
);
}

View File

@ -0,0 +1,120 @@
'use client';
import * as React from 'react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { type DropdownMenuProps, DropdownMenuItemIndicator } from '@radix-ui/react-dropdown-menu';
import { Check, Eye, Pencil2, Pen } from '@/components/ui/icons';
import { useEditorRef, usePlateState, usePluginOption } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { ToolbarButton } from './Toolbar';
export function ModeToolbarButton(props: DropdownMenuProps) {
const editor = useEditorRef();
const [readOnly, setReadOnly] = usePlateState('readOnly');
const [open, setOpen] = React.useState(false);
const isSuggesting = usePluginOption(SuggestionPlugin, 'isSuggesting');
let value = 'editing';
if (readOnly) value = 'viewing';
if (isSuggesting) value = 'suggestion';
const item: Record<string, { icon: React.ReactNode; label: string }> = {
editing: {
icon: <Pen />,
label: 'Editing'
},
suggestion: {
icon: <Pencil2 />,
label: 'Suggestion'
},
viewing: {
icon: <Eye />,
label: 'Viewing'
}
};
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Editing mode" isDropdown>
{item[value].icon}
<span className="hidden lg:inline">{item[value].label}</span>
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[180px]" align="start">
<DropdownMenuRadioGroup
value={value}
onValueChange={(newValue) => {
if (newValue === 'viewing') {
setReadOnly(true);
return;
} else {
setReadOnly(false);
}
if (newValue === 'suggestion') {
editor.setOption(SuggestionPlugin, 'isSuggesting', true);
return;
} else {
editor.setOption(SuggestionPlugin, 'isSuggesting', false);
}
if (newValue === 'editing') {
editor.tf.focus();
return;
}
}}>
<DropdownMenuRadioItem
className="*:[svg]:text-muted-foreground pl-2 *:first:[span]:hidden"
value="editing">
<Indicator />
{item.editing.icon}
{item.editing.label}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
className="*:[svg]:text-muted-foreground pl-2 *:first:[span]:hidden"
value="viewing">
<Indicator />
{item.viewing.icon}
{item.viewing.label}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
className="*:[svg]:text-muted-foreground pl-2 *:first:[span]:hidden"
value="suggestion">
<Indicator />
{item.suggestion.icon}
{item.suggestion.label}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Indicator() {
return (
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check />
</DropdownMenuItemIndicator>
</span>
);
}

View File

@ -0,0 +1,272 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import { TablePlugin, useTableMergeState } from '@platejs/table/react';
// import {
// ArrowDown,
// ArrowLeft,
// ArrowRight,
// ArrowUp,
// Combine,
// Grid3x3Icon,
// Table,
// Trash2Icon,
// Ungroup,
// XIcon
// } from 'lucide-react';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Merge,
Grid3X3,
Table,
Trash2,
Ungroup,
Xmark
} from '@/components/ui/icons';
import { KEYS } from 'platejs';
import { useEditorPlugin, useEditorSelector } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { ToolbarButton } from './Toolbar';
export function TableToolbarButton(props: DropdownMenuProps) {
const tableSelected = useEditorSelector(
(editor) => editor.api.some({ match: { type: KEYS.table } }),
[]
);
const { editor, tf } = useEditorPlugin(TablePlugin);
const [open, setOpen] = React.useState(false);
const mergeState = useTableMergeState();
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Table" isDropdown>
<Table />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex w-[180px] min-w-0 flex-col" align="start">
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<div className="size-4">
<Grid3X3 />
</div>
<span>Table</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="m-0 p-0">
<TablePicker />
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
disabled={!tableSelected}>
<div className="size-4" />
<span>Cell</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!mergeState.canMerge}
onSelect={() => {
tf.table.merge();
editor.tf.focus();
}}>
<div className="size-4">
<Merge />
</div>
Merge cells
</DropdownMenuItem>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!mergeState.canSplit}
onSelect={() => {
tf.table.split();
editor.tf.focus();
}}>
<Ungroup />
Split cell
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
disabled={!tableSelected}>
<div className="size-4" />
<span>Row</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.insert.tableRow({ before: true });
editor.tf.focus();
}}>
<ArrowUp />
Insert row before
</DropdownMenuItem>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.insert.tableRow();
editor.tf.focus();
}}>
<ArrowDown />
Insert row after
</DropdownMenuItem>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.remove.tableRow();
editor.tf.focus();
}}>
<div className="size-4">
<Xmark />
</div>
Delete row
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
disabled={!tableSelected}>
<div className="size-4" />
<span>Column</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.insert.tableColumn({ before: true });
editor.tf.focus();
}}>
<ArrowLeft />
Insert column before
</DropdownMenuItem>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.insert.tableColumn();
editor.tf.focus();
}}>
<ArrowRight />
Insert column after
</DropdownMenuItem>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.remove.tableColumn();
editor.tf.focus();
}}>
<div className="size-4">
<Xmark />
</div>
Delete column
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.remove.table();
editor.tf.focus();
}}>
<div className="size-4">
<Trash2 />
</div>
Delete table
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function TablePicker() {
const { editor, tf } = useEditorPlugin(TablePlugin);
const [tablePicker, setTablePicker] = React.useState({
grid: Array.from({ length: 8 }, () => Array.from({ length: 8 }).fill(0)),
size: { colCount: 0, rowCount: 0 }
});
const onCellMove = (rowIndex: number, colIndex: number) => {
const newGrid = [...tablePicker.grid];
for (let i = 0; i < newGrid.length; i++) {
for (let j = 0; j < newGrid[i].length; j++) {
newGrid[i][j] = i >= 0 && i <= rowIndex && j >= 0 && j <= colIndex ? 1 : 0;
}
}
setTablePicker({
grid: newGrid,
size: { colCount: colIndex + 1, rowCount: rowIndex + 1 }
});
};
return (
<div
className="m-0 flex! flex-col p-0"
onClick={() => {
tf.insert.table(tablePicker.size, { select: true });
editor.tf.focus();
}}>
<div className="grid size-[130px] grid-cols-8 gap-0.5 p-1">
{tablePicker.grid.map((rows, rowIndex) =>
rows.map((value, columIndex) => {
return (
<div
key={`(${rowIndex},${columIndex})`}
className={cn(
'bg-secondary col-span-1 size-3 border border-solid',
!!value && 'border-current'
)}
onMouseMove={() => {
onCellMove(rowIndex, columIndex);
}}
/>
);
})
)}
</div>
<div className="text-center text-xs text-current">
{tablePicker.size.rowCount} x {tablePicker.size.colCount}
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import { useToggleToolbarButton, useToggleToolbarButtonState } from '@platejs/toggle/react';
import { DescendingSorting } from '@/components/ui/icons';
import { ToolbarButton } from './Toolbar';
export function ToggleToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {
const state = useToggleToolbarButtonState();
const { props: buttonProps } = useToggleToolbarButton(state);
return (
<ToolbarButton {...props} {...buttonProps} tooltip="Toggle">
<DescendingSorting />
</ToolbarButton>
);
}