mirror of https://github.com/buster-so/buster.git
Add additional toolbar buttons
This commit is contained in:
parent
d46688bc5b
commit
04dba563bd
|
@ -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
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './AlertDialogBase';
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue