feat: add report content update endpoint and integrate Platejs editor for markdown editing

This commit is contained in:
Anish Sarkar 2026-02-16 00:11:34 +05:30
parent cb759b64fe
commit 1995fe9ec1
73 changed files with 7447 additions and 77 deletions

View file

@ -1,8 +1,9 @@
"""
Report routes for read, export (PDF/DOCX), and delete operations.
Report routes for read, update, export (PDF/DOCX), and delete operations.
No create or update endpoints here reports are generated inline by the
agent tool during chat and stored as Markdown in the database.
Reports are generated inline by the agent tool during chat and stored as
Markdown in the database. Users can edit report content via the Plate editor
and save changes through the PUT endpoint.
Export to PDF/DOCX is on-demand PDF uses pypandoc (MarkdownTypst) + typst-py
(TypstPDF); DOCX uses pypandoc directly.
@ -33,7 +34,7 @@ from app.db import (
User,
get_async_session,
)
from app.schemas import ReportContentRead, ReportRead
from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead
from app.schemas.reports import ReportVersionInfo
from app.users import current_active_user
from app.utils.rbac import check_search_space_access
@ -259,6 +260,47 @@ async def read_report_content(
) from None
@router.put("/reports/{report_id}/content", response_model=ReportContentRead)
async def update_report_content(
report_id: int,
body: ReportContentUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update the Markdown content of a report.
The caller must be a member of the search space the report belongs to.
Returns the updated report content including version siblings.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report.content = body.content
session.add(report)
await session.commit()
await session.refresh(report)
versions = await _get_version_siblings(session, report)
return ReportContentRead(
id=report.id,
title=report.title,
content=report.content,
report_metadata=report.report_metadata,
report_group_id=report.report_group_id,
versions=versions,
)
except HTTPException:
raise
except SQLAlchemyError:
await session.rollback()
raise HTTPException(
status_code=500,
detail="Database error occurred while updating report content",
) from None
@router.get("/reports/{report_id}/export")
async def export_report(
report_id: int,

View file

@ -76,7 +76,13 @@ from .rbac_schemas import (
RoleUpdate,
UserSearchSpaceAccess,
)
from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo
from .reports import (
ReportBase,
ReportContentRead,
ReportContentUpdate,
ReportRead,
ReportVersionInfo,
)
from .search_source_connector import (
MCPConnectorCreate,
MCPConnectorRead,
@ -189,6 +195,7 @@ __all__ = [
# Report schemas
"ReportBase",
"ReportContentRead",
"ReportContentUpdate",
"ReportRead",
"ReportVersionInfo",
"RoleCreate",

View file

@ -51,3 +51,9 @@ class ReportContentRead(BaseModel):
class Config:
from_attributes = True
class ReportContentUpdate(BaseModel):
"""Schema for updating a report's Markdown content."""
content: str

View file

@ -4,6 +4,8 @@
@plugin "tailwindcss-animate";
@plugin "tailwind-scrollbar-hide";
@custom-variant dark (&:is(.dark *));
@theme {
@ -46,6 +48,8 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--syntax-bg: #f5f5f5;
--brand: oklch(0.623 0.214 259.815);
--highlight: oklch(0.852 0.199 91.936);
}
.dark {
@ -82,6 +86,8 @@
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--syntax-bg: #1e1e1e;
--brand: oklch(0.707 0.165 254.624);
--highlight: oklch(0.852 0.199 91.936);
}
@theme inline {
@ -123,6 +129,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-brand: var(--brand);
--color-highlight: var(--highlight);
}
@layer base {

View file

@ -1,21 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@plate": "https://platejs.org/r/{name}.json"
}
}

View file

@ -0,0 +1,107 @@
'use client';
import { useEffect, useRef } from 'react';
import { MarkdownPlugin } from '@platejs/markdown';
import { Plate, usePlateEditor } from 'platejs/react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { AutoformatKit } from '@/components/editor/plugins/autoformat-classic-kit';
import { BasicNodesKit } from '@/components/editor/plugins/basic-nodes-kit';
import { CalloutKit } from '@/components/editor/plugins/callout-kit';
import { CodeBlockKit } from '@/components/editor/plugins/code-block-kit';
import { FloatingToolbarKit } from '@/components/editor/plugins/floating-toolbar-kit';
import { IndentKit } from '@/components/editor/plugins/indent-kit';
import { LinkKit } from '@/components/editor/plugins/link-kit';
import { ListKit } from '@/components/editor/plugins/list-classic-kit';
import { MathKit } from '@/components/editor/plugins/math-kit';
import { SelectionKit } from '@/components/editor/plugins/selection-kit';
import { SlashCommandKit } from '@/components/editor/plugins/slash-command-kit';
import { TableKit } from '@/components/editor/plugins/table-kit';
import { ToggleKit } from '@/components/editor/plugins/toggle-kit';
import { Editor, EditorContainer } from '@/components/ui/editor';
interface PlateEditorProps {
/** Markdown string to load as initial content */
markdown?: string;
/** Called when the editor content changes, with serialized markdown */
onMarkdownChange?: (markdown: string) => void;
/** Whether the editor is read-only */
readOnly?: boolean;
/** Placeholder text */
placeholder?: string;
/** Editor container variant */
variant?: 'default' | 'demo' | 'comment' | 'select';
/** Editor text variant */
editorVariant?: 'default' | 'demo' | 'fullWidth' | 'none';
/** Additional className for the container */
className?: string;
}
export function PlateEditor({
markdown,
onMarkdownChange,
readOnly = false,
placeholder = 'Type...',
variant = 'default',
editorVariant = 'default',
className,
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
const editor = usePlateEditor({
plugins: [
...BasicNodesKit,
...TableKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...CalloutKit,
...ToggleKit,
...IndentKit,
...MathKit,
...SelectionKit,
...SlashCommandKit,
...FloatingToolbarKit,
...AutoformatKit,
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath],
},
}),
],
// Use markdown deserialization for initial value if provided
value: markdown
? (editor) => editor.getApi(MarkdownPlugin).markdown.deserialize(markdown)
: undefined,
});
// Update editor content when markdown prop changes externally
// (e.g., version switching in report panel)
useEffect(() => {
if (markdown !== undefined && markdown !== lastMarkdownRef.current) {
lastMarkdownRef.current = markdown;
const newValue = editor.getApi(MarkdownPlugin).markdown.deserialize(markdown);
editor.tf.reset();
editor.tf.setValue(newValue);
}
}, [markdown, editor]);
return (
<Plate
editor={editor}
readOnly={readOnly}
onChange={({ value }) => {
if (onMarkdownChange) {
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
lastMarkdownRef.current = md;
onMarkdownChange(md);
}
}}
>
<EditorContainer variant={variant} className={className}>
<Editor variant={editorVariant} placeholder={placeholder} readOnly={readOnly} />
</EditorContainer>
</Plate>
);
}

View file

@ -0,0 +1,250 @@
'use client';
import type { AutoformatBlockRule, AutoformatRule } from '@platejs/autoformat';
import type { SlateEditor } from 'platejs';
import {
autoformatArrow,
autoformatLegal,
autoformatLegalHtml,
autoformatMath,
AutoformatPlugin,
autoformatPunctuation,
autoformatSmartQuotes,
} from '@platejs/autoformat';
import { insertEmptyCodeBlock } from '@platejs/code-block';
import { toggleList, toggleTaskList, unwrapList } from '@platejs/list-classic';
import { openNextToggles } from '@platejs/toggle/react';
import { ElementApi, isType, KEYS } from 'platejs';
const preFormat: AutoformatBlockRule['preFormat'] = (editor) =>
unwrapList(editor);
const format = (editor: SlateEditor, customFormatting: any) => {
if (editor.selection) {
const parentEntry = editor.api.parent(editor.selection);
if (!parentEntry) return;
const [node] = parentEntry;
if (ElementApi.isElement(node) && !isType(editor, node, KEYS.codeBlock)) {
customFormatting();
}
}
};
const formatTaskList = (editor: SlateEditor, defaultChecked = false) => {
format(editor, () => toggleTaskList(editor, defaultChecked));
};
const formatList = (editor: SlateEditor, elementType: string) => {
format(editor, () =>
toggleList(editor, {
type: elementType,
})
);
};
const autoformatMarks: AutoformatRule[] = [
{
match: '***',
mode: 'mark',
type: [KEYS.bold, KEYS.italic],
},
{
match: '__*',
mode: 'mark',
type: [KEYS.underline, KEYS.italic],
},
{
match: '__**',
mode: 'mark',
type: [KEYS.underline, KEYS.bold],
},
{
match: '___***',
mode: 'mark',
type: [KEYS.underline, KEYS.bold, KEYS.italic],
},
{
match: '**',
mode: 'mark',
type: KEYS.bold,
},
{
match: '__',
mode: 'mark',
type: KEYS.underline,
},
{
match: '*',
mode: 'mark',
type: KEYS.italic,
},
{
match: '_',
mode: 'mark',
type: KEYS.italic,
},
{
match: '~~',
mode: 'mark',
type: KEYS.strikethrough,
},
{
match: '^',
mode: 'mark',
type: KEYS.sup,
},
{
match: '~',
mode: 'mark',
type: KEYS.sub,
},
{
match: '==',
mode: 'mark',
type: KEYS.highlight,
},
{
match: '≡',
mode: 'mark',
type: KEYS.highlight,
},
{
match: '`',
mode: 'mark',
type: KEYS.code,
},
];
const autoformatBlocks: AutoformatRule[] = [
{
match: '# ',
mode: 'block',
preFormat,
type: KEYS.h1,
},
{
match: '## ',
mode: 'block',
preFormat,
type: KEYS.h2,
},
{
match: '### ',
mode: 'block',
preFormat,
type: KEYS.h3,
},
{
match: '#### ',
mode: 'block',
preFormat,
type: KEYS.h4,
},
{
match: '##### ',
mode: 'block',
preFormat,
type: KEYS.h5,
},
{
match: '###### ',
mode: 'block',
preFormat,
type: KEYS.h6,
},
{
match: '> ',
mode: 'block',
preFormat,
type: KEYS.blockquote,
},
{
match: '```',
mode: 'block',
preFormat,
type: KEYS.codeBlock,
format: (editor) => {
insertEmptyCodeBlock(editor, {
defaultType: KEYS.p,
insertNodesOptions: { select: true },
});
},
},
{
match: '+ ',
mode: 'block',
preFormat: openNextToggles,
type: KEYS.toggle,
},
{
match: ['---', '—-', '___ '],
mode: 'block',
type: KEYS.hr,
format: (editor) => {
editor.tf.setNodes({ type: KEYS.hr });
editor.tf.insertNodes({
children: [{ text: '' }],
type: KEYS.p,
});
},
},
];
const autoformatLists: AutoformatRule[] = [
{
match: ['* ', '- '],
mode: 'block',
preFormat,
type: KEYS.li,
format: (editor) => formatList(editor, KEYS.ulClassic),
},
{
match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
matchByRegex: true,
mode: 'block',
preFormat,
type: KEYS.li,
format: (editor) => formatList(editor, KEYS.olClassic),
},
{
match: '[] ',
mode: 'block',
type: KEYS.taskList,
format: (editor) => formatTaskList(editor, false),
},
{
match: '[x] ',
mode: 'block',
type: KEYS.taskList,
format: (editor) => formatTaskList(editor, true),
},
];
export const AutoformatKit = [
AutoformatPlugin.configure({
options: {
enableUndoOnDelete: true,
rules: [
...autoformatBlocks,
...autoformatMarks,
...autoformatSmartQuotes,
...autoformatPunctuation,
...autoformatLegal,
...autoformatLegalHtml,
...autoformatArrow,
...autoformatMath,
...autoformatLists,
].map((rule) => ({
...rule,
query: (editor) =>
!editor.api.some({
match: { type: editor.getType(KEYS.codeBlock) },
}),
})),
},
}),
];

View file

@ -0,0 +1,35 @@
import {
BaseBlockquotePlugin,
BaseH1Plugin,
BaseH2Plugin,
BaseH3Plugin,
BaseH4Plugin,
BaseH5Plugin,
BaseH6Plugin,
BaseHorizontalRulePlugin,
} from '@platejs/basic-nodes';
import { BaseParagraphPlugin } from 'platejs';
import { BlockquoteElementStatic } from '@/components/ui/blockquote-node-static';
import {
H1ElementStatic,
H2ElementStatic,
H3ElementStatic,
H4ElementStatic,
H5ElementStatic,
H6ElementStatic,
} from '@/components/ui/heading-node-static';
import { HrElementStatic } from '@/components/ui/hr-node-static';
import { ParagraphElementStatic } from '@/components/ui/paragraph-node-static';
export const BaseBasicBlocksKit = [
BaseParagraphPlugin.withComponent(ParagraphElementStatic),
BaseH1Plugin.withComponent(H1ElementStatic),
BaseH2Plugin.withComponent(H2ElementStatic),
BaseH3Plugin.withComponent(H3ElementStatic),
BaseH4Plugin.withComponent(H4ElementStatic),
BaseH5Plugin.withComponent(H5ElementStatic),
BaseH6Plugin.withComponent(H6ElementStatic),
BaseBlockquotePlugin.withComponent(BlockquoteElementStatic),
BaseHorizontalRulePlugin.withComponent(HrElementStatic),
];

View file

@ -0,0 +1,86 @@
'use client';
import {
BlockquotePlugin,
H1Plugin,
H2Plugin,
H3Plugin,
H4Plugin,
H5Plugin,
H6Plugin,
HorizontalRulePlugin,
} from '@platejs/basic-nodes/react';
import { ParagraphPlugin } from 'platejs/react';
import { BlockquoteElement } from '@/components/ui/blockquote-node';
import {
H1Element,
H2Element,
H3Element,
H4Element,
H5Element,
H6Element,
} from '@/components/ui/heading-node';
import { HrElement } from '@/components/ui/hr-node';
import { ParagraphElement } from '@/components/ui/paragraph-node';
export const BasicBlocksKit = [
ParagraphPlugin.withComponent(ParagraphElement),
H1Plugin.configure({
node: {
component: H1Element,
},
rules: {
break: { empty: 'reset' },
},
shortcuts: { toggle: { keys: 'mod+alt+1' } },
}),
H2Plugin.configure({
node: {
component: H2Element,
},
rules: {
break: { empty: 'reset' },
},
shortcuts: { toggle: { keys: 'mod+alt+2' } },
}),
H3Plugin.configure({
node: {
component: H3Element,
},
rules: {
break: { empty: 'reset' },
},
shortcuts: { toggle: { keys: 'mod+alt+3' } },
}),
H4Plugin.configure({
node: {
component: H4Element,
},
rules: {
break: { empty: 'reset' },
},
shortcuts: { toggle: { keys: 'mod+alt+4' } },
}),
H5Plugin.configure({
node: {
component: H5Element,
},
rules: {
break: { empty: 'reset' },
},
}),
H6Plugin.configure({
node: {
component: H6Element,
},
rules: {
break: { empty: 'reset' },
},
}),
BlockquotePlugin.configure({
node: { component: BlockquoteElement },
shortcuts: { toggle: { keys: 'mod+shift+period' } },
}),
HorizontalRulePlugin.withComponent(HrElement),
];

View file

@ -0,0 +1,27 @@
import {
BaseBoldPlugin,
BaseCodePlugin,
BaseHighlightPlugin,
BaseItalicPlugin,
BaseKbdPlugin,
BaseStrikethroughPlugin,
BaseSubscriptPlugin,
BaseSuperscriptPlugin,
BaseUnderlinePlugin,
} from '@platejs/basic-nodes';
import { CodeLeafStatic } from '@/components/ui/code-node-static';
import { HighlightLeafStatic } from '@/components/ui/highlight-node-static';
import { KbdLeafStatic } from '@/components/ui/kbd-node-static';
export const BaseBasicMarksKit = [
BaseBoldPlugin,
BaseItalicPlugin,
BaseUnderlinePlugin,
BaseCodePlugin.withComponent(CodeLeafStatic),
BaseStrikethroughPlugin,
BaseSubscriptPlugin,
BaseSuperscriptPlugin,
BaseHighlightPlugin.withComponent(HighlightLeafStatic),
BaseKbdPlugin.withComponent(KbdLeafStatic),
];

View file

@ -0,0 +1,41 @@
'use client';
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
KbdPlugin,
StrikethroughPlugin,
SubscriptPlugin,
SuperscriptPlugin,
UnderlinePlugin,
} from '@platejs/basic-nodes/react';
import { CodeLeaf } from '@/components/ui/code-node';
import { HighlightLeaf } from '@/components/ui/highlight-node';
import { KbdLeaf } from '@/components/ui/kbd-node';
export const BasicMarksKit = [
BoldPlugin,
ItalicPlugin,
UnderlinePlugin,
CodePlugin.configure({
node: { component: CodeLeaf },
shortcuts: { toggle: { keys: 'mod+e' } },
}),
StrikethroughPlugin.configure({
shortcuts: { toggle: { keys: 'mod+shift+x' } },
}),
SubscriptPlugin.configure({
shortcuts: { toggle: { keys: 'mod+comma' } },
}),
SuperscriptPlugin.configure({
shortcuts: { toggle: { keys: 'mod+period' } },
}),
HighlightPlugin.configure({
node: { component: HighlightLeaf },
shortcuts: { toggle: { keys: 'mod+shift+h' } },
}),
KbdPlugin.withComponent(KbdLeaf),
];

View file

@ -0,0 +1,6 @@
'use client';
import { BasicBlocksKit } from './basic-blocks-kit';
import { BasicMarksKit } from './basic-marks-kit';
export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];

View file

@ -0,0 +1,8 @@
'use client';
import { CalloutPlugin } from '@platejs/callout/react';
import { CalloutElement } from '@/components/ui/callout-node';
export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)];

View file

@ -0,0 +1,23 @@
import {
BaseCodeBlockPlugin,
BaseCodeLinePlugin,
BaseCodeSyntaxPlugin,
} from '@platejs/code-block';
import { all, createLowlight } from 'lowlight';
import {
CodeBlockElementStatic,
CodeLineElementStatic,
CodeSyntaxLeafStatic,
} from '@/components/ui/code-block-node-static';
const lowlight = createLowlight(all);
export const BaseCodeBlockKit = [
BaseCodeBlockPlugin.configure({
node: { component: CodeBlockElementStatic },
options: { lowlight },
}),
BaseCodeLinePlugin.withComponent(CodeLineElementStatic),
BaseCodeSyntaxPlugin.withComponent(CodeSyntaxLeafStatic),
];

View file

@ -0,0 +1,26 @@
'use client';
import {
CodeBlockPlugin,
CodeLinePlugin,
CodeSyntaxPlugin,
} from '@platejs/code-block/react';
import { all, createLowlight } from 'lowlight';
import {
CodeBlockElement,
CodeLineElement,
CodeSyntaxLeaf,
} from '@/components/ui/code-block-node';
const lowlight = createLowlight(all);
export const CodeBlockKit = [
CodeBlockPlugin.configure({
node: { component: CodeBlockElement },
options: { lowlight },
shortcuts: { toggle: { keys: 'mod+alt+8' } },
}),
CodeLinePlugin.withComponent(CodeLineElement),
CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
];

View file

@ -0,0 +1,19 @@
'use client';
import { createPlatePlugin } from 'platejs/react';
import { FloatingToolbar } from '@/components/ui/floating-toolbar';
import { FloatingToolbarButtons } from '@/components/ui/floating-toolbar-buttons';
export const FloatingToolbarKit = [
createPlatePlugin({
key: 'floating-toolbar',
render: {
afterEditable: () => (
<FloatingToolbar>
<FloatingToolbarButtons />
</FloatingToolbar>
),
},
}),
];

View file

@ -0,0 +1,19 @@
'use client';
import { IndentPlugin } from '@platejs/indent/react';
import { KEYS } from 'platejs';
export const IndentKit = [
IndentPlugin.configure({
inject: {
targetPlugins: [
...KEYS.heading,
KEYS.p,
KEYS.blockquote,
KEYS.codeBlock,
KEYS.toggle,
],
},
}),
];

View file

@ -0,0 +1,5 @@
import { BaseLinkPlugin } from '@platejs/link';
import { LinkElementStatic } from '@/components/ui/link-node-static';
export const BaseLinkKit = [BaseLinkPlugin.withComponent(LinkElementStatic)];

View file

@ -0,0 +1,15 @@
'use client';
import { LinkPlugin } from '@platejs/link/react';
import { LinkElement } from '@/components/ui/link-node';
import { LinkFloatingToolbar } from '@/components/ui/link-toolbar';
export const LinkKit = [
LinkPlugin.configure({
render: {
node: LinkElement,
afterEditable: () => <LinkFloatingToolbar />,
},
}),
];

View file

@ -0,0 +1,36 @@
'use client';
import {
BulletedListPlugin,
ListItemContentPlugin,
ListItemPlugin,
ListPlugin,
NumberedListPlugin,
TaskListPlugin,
} from '@platejs/list-classic/react';
import {
BulletedListElement,
ListItemElement,
NumberedListElement,
TaskListElement,
} from '@/components/ui/list-classic-node';
export const ListKit = [
ListPlugin,
ListItemPlugin,
ListItemContentPlugin,
BulletedListPlugin.configure({
node: { component: BulletedListElement },
shortcuts: { toggle: { keys: 'mod+alt+5' } },
}),
NumberedListPlugin.configure({
node: { component: NumberedListElement },
shortcuts: { toggle: { keys: 'mod+alt+6' } },
}),
TaskListPlugin.configure({
node: { component: TaskListElement },
shortcuts: { toggle: { keys: 'mod+alt+7' } },
}),
ListItemPlugin.withComponent(ListItemElement),
];

View file

@ -0,0 +1,11 @@
'use client';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import { EquationElement, InlineEquationElement } from '@/components/ui/equation-node';
export const MathKit = [
EquationPlugin.withComponent(EquationElement),
InlineEquationPlugin.withComponent(InlineEquationElement),
];

View file

@ -0,0 +1,23 @@
'use client';
import { BlockSelectionPlugin } from '@platejs/selection/react';
import { BlockSelection } from '@/components/ui/block-selection';
export const SelectionKit = [
BlockSelectionPlugin.configure({
render: {
belowRootNodes: BlockSelection as any,
},
options: {
isSelectable: (element) => {
// Exclude specific block types from selection
if (['code_line', 'td', 'th'].includes(element.type as string)) {
return false;
}
return true;
},
},
}),
];

View file

@ -0,0 +1,21 @@
'use client';
import { SlashInputPlugin, SlashPlugin } from '@platejs/slash-command/react';
import { KEYS } from 'platejs';
import { SlashInputElement } from '@/components/ui/slash-node';
export const SlashCommandKit = [
SlashPlugin.configure({
options: {
trigger: '/',
triggerPreviousCharPattern: /^\s?$/,
triggerQuery: (editor) =>
!editor.api.some({
match: { type: editor.getType(KEYS.codeBlock) },
}),
},
}),
SlashInputPlugin.withComponent(SlashInputElement),
];

View file

@ -0,0 +1,20 @@
import {
BaseTableCellHeaderPlugin,
BaseTableCellPlugin,
BaseTablePlugin,
BaseTableRowPlugin,
} from '@platejs/table';
import {
TableCellElementStatic,
TableCellHeaderElementStatic,
TableElementStatic,
TableRowElementStatic,
} from '@/components/ui/table-node-static';
export const BaseTableKit = [
BaseTablePlugin.withComponent(TableElementStatic),
BaseTableRowPlugin.withComponent(TableRowElementStatic),
BaseTableCellPlugin.withComponent(TableCellElementStatic),
BaseTableCellHeaderPlugin.withComponent(TableCellHeaderElementStatic),
];

View file

@ -0,0 +1,22 @@
'use client';
import {
TableCellHeaderPlugin,
TableCellPlugin,
TablePlugin,
TableRowPlugin,
} from '@platejs/table/react';
import {
TableCellElement,
TableCellHeaderElement,
TableElement,
TableRowElement,
} from '@/components/ui/table-node';
export const TableKit = [
TablePlugin.withComponent(TableElement),
TableRowPlugin.withComponent(TableRowElement),
TableCellPlugin.withComponent(TableCellElement),
TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
];

View file

@ -0,0 +1,13 @@
'use client';
import { TogglePlugin } from '@platejs/toggle/react';
import { ToggleElement } from '@/components/ui/toggle-node';
export const ToggleKit = [
TogglePlugin.configure({
node: { component: ToggleElement },
shortcuts: { toggle: { keys: 'mod+alt+9' } },
}),
];

View file

@ -0,0 +1,184 @@
'use client';
import type { PlateEditor } from 'platejs/react';
import { insertCallout } from '@platejs/callout';
import { insertCodeBlock, toggleCodeBlock } from '@platejs/code-block';
import { triggerFloatingLink } from '@platejs/link/react';
import { insertInlineEquation } from '@platejs/math';
import { TablePlugin } from '@platejs/table/react';
import {
type NodeEntry,
type Path,
type TElement,
KEYS,
PathApi,
} from 'platejs';
const insertList = (editor: PlateEditor, type: string) => {
editor.tf.insertNodes(
editor.api.create.block({
indent: 1,
listStyleType: type,
}),
{ select: true }
);
};
const insertBlockMap: Record<
string,
(editor: PlateEditor, type: string) => void
> = {
[KEYS.listTodo]: insertList,
[KEYS.ol]: insertList,
[KEYS.ul]: insertList,
[KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),
[KEYS.table]: (editor) =>
editor.getTransforms(TablePlugin).insert.table({}, { select: true }),
[KEYS.callout]: (editor) => insertCallout(editor, { select: true }),
[KEYS.toggle]: (editor) => {
editor.tf.insertNodes(
editor.api.create.block({ type: KEYS.toggle }),
{ select: true }
);
},
};
const insertInlineMap: Record<
string,
(editor: PlateEditor, type: string) => void
> = {
[KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),
[KEYS.equation]: (editor) => insertInlineEquation(editor),
};
type InsertBlockOptions = {
upsert?: boolean;
};
export const insertBlock = (
editor: PlateEditor,
type: string,
options: InsertBlockOptions = {}
) => {
const { upsert = false } = options;
editor.tf.withoutNormalizing(() => {
const block = editor.api.block();
if (!block) return;
const [currentNode, path] = block;
const isCurrentBlockEmpty = editor.api.isEmpty(currentNode);
const currentBlockType = getBlockType(currentNode);
const isSameBlockType = type === currentBlockType;
if (upsert && isCurrentBlockEmpty && isSameBlockType) {
return;
}
if (type in insertBlockMap) {
insertBlockMap[type](editor, type);
} else {
editor.tf.insertNodes(editor.api.create.block({ type }), {
at: PathApi.next(path),
select: true,
});
}
if (!isSameBlockType) {
editor.tf.removeNodes({ previousEmptyBlock: true });
}
});
};
export const insertInlineElement = (editor: PlateEditor, type: string) => {
if (insertInlineMap[type]) {
insertInlineMap[type](editor, type);
}
};
const setList = (
editor: PlateEditor,
type: string,
entry: NodeEntry<TElement>
) => {
editor.tf.setNodes(
editor.api.create.block({
indent: 1,
listStyleType: type,
}),
{
at: entry[1],
}
);
};
const setBlockMap: Record<
string,
(editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => void
> = {
[KEYS.listTodo]: setList,
[KEYS.ol]: setList,
[KEYS.ul]: setList,
[KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),
[KEYS.callout]: (editor, _type, entry) => {
editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] });
},
[KEYS.toggle]: (editor, _type, entry) => {
editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] });
},
};
export const setBlockType = (
editor: PlateEditor,
type: string,
{ at }: { at?: Path } = {}
) => {
editor.tf.withoutNormalizing(() => {
const setEntry = (entry: NodeEntry<TElement>) => {
const [node, path] = entry;
if (node[KEYS.listType]) {
editor.tf.unsetNodes([KEYS.listType, 'indent'], { at: path });
}
if (type in setBlockMap) {
return setBlockMap[type](editor, type, entry);
}
if (node.type !== type) {
editor.tf.setNodes({ type }, { at: path });
}
};
if (at) {
const entry = editor.api.node<TElement>(at);
if (entry) {
setEntry(entry);
return;
}
}
const entries = editor.api.blocks({ mode: 'lowest' });
entries.forEach((entry) => {
setEntry(entry);
});
});
};
export const getBlockType = (block: TElement) => {
if (block[KEYS.listType]) {
if (block[KEYS.listType] === KEYS.ol) {
return KEYS.ol;
}
if (block[KEYS.listType] === KEYS.listTodo) {
return KEYS.listTodo;
}
return KEYS.ul;
}
return block.type;
};

View file

@ -1,11 +1,12 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { ChevronDownIcon, SaveIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { PlateEditor } from "@/components/editor/plate-editor";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
import {
@ -112,6 +113,10 @@ function ReportPanelContent({
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
const [saving, setSaving] = useState(false);
// Editor state — tracks the latest markdown from the Plate editor
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
// Version state
const [activeReportId, setActiveReportId] = useState(reportId);
@ -165,17 +170,25 @@ function ReportPanelContent({
};
}, [activeReportId, shareToken]);
// Copy markdown content
// The current markdown: use edited version if available, otherwise original
const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null;
// Reset edited markdown when switching versions or reports
useEffect(() => {
setEditedMarkdown(null);
}, [activeReportId]);
// Copy markdown content (uses latest editor content)
const handleCopy = useCallback(async () => {
if (!reportContent?.content) return;
if (!currentMarkdown) return;
try {
await navigator.clipboard.writeText(reportContent.content);
await navigator.clipboard.writeText(currentMarkdown);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [reportContent?.content]);
}, [currentMarkdown]);
// Export report
const handleExport = useCallback(
@ -188,9 +201,9 @@ function ReportPanelContent({
.slice(0, 80) || "report";
try {
if (format === "md") {
// Download markdown content directly as a .md file
if (!reportContent?.content) return;
const blob = new Blob([reportContent.content], {
// Download markdown content directly as a .md file (uses latest editor content)
if (!currentMarkdown) return;
const blob = new Blob([currentMarkdown], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@ -227,9 +240,40 @@ function ReportPanelContent({
setExporting(null);
}
},
[activeReportId, title, reportContent?.content]
[activeReportId, title, currentMarkdown]
);
// Save edited report content
const handleSave = useCallback(async () => {
if (!currentMarkdown || !activeReportId) return;
setSaving(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: currentMarkdown }),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to save report" }));
throw new Error(errorData.detail || "Failed to save report");
}
// Update local state to reflect saved content
setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev));
setEditedMarkdown(null);
toast.success("Report saved successfully");
} catch (err) {
console.error("Error saving report:", err);
toast.error(err instanceof Error ? err.message : "Failed to save report");
} finally {
setSaving(false);
}
}, [activeReportId, currentMarkdown]);
// Show full-page skeleton only on initial load (no data loaded yet).
// Once we have versions/content from a prior fetch, keep the action bar visible.
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
@ -258,6 +302,20 @@ function ReportPanelContent({
{/* Action bar — always visible after initial load */}
<div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2">
{/* Save button — only shown for authenticated users with unsaved edits */}
{!shareToken && editedMarkdown !== null && (
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={saving}
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
>
<SaveIcon className="size-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
)}
{/* Copy button */}
<Button
variant="outline"
@ -370,8 +428,8 @@ function ReportPanelContent({
)}
</div>
{/* Report content — skeleton/error/content shown only in this area */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{/* Report content — skeleton/error/editor shown only in this area */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
<ReportPanelSkeleton />
) : error || !reportContent ? (
@ -381,13 +439,17 @@ function ReportPanelContent({
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div>
</div>
) : reportContent.content ? (
<PlateEditor
markdown={reportContent.content}
onMarkdownChange={shareToken ? undefined : setEditedMarkdown}
readOnly={!!shareToken}
placeholder="Report content..."
editorVariant="default"
/>
) : (
<div className="px-5 py-5">
{reportContent.content ? (
<MarkdownViewer content={reportContent.content} />
) : (
<p className="text-muted-foreground italic">No content available.</p>
)}
<p className="text-muted-foreground italic">No content available.</p>
</div>
)}
</div>

View file

@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import { DndPlugin } from '@platejs/dnd';
import { useBlockSelected } from '@platejs/selection/react';
import { cva } from 'class-variance-authority';
import { type PlateElementProps, usePluginOption } from 'platejs/react';
export const blockSelectionVariants = cva(
'pointer-events-none absolute inset-0 z-1 bg-brand/[.13] transition-opacity',
{
defaultVariants: {
active: true,
},
variants: {
active: {
false: 'opacity-0',
true: 'opacity-100',
},
},
}
);
export function BlockSelection(props: PlateElementProps) {
const isBlockSelected = useBlockSelected();
const isDragging = usePluginOption(DndPlugin, 'isDragging');
if (
!isBlockSelected ||
props.plugin.key === 'tr' ||
props.plugin.key === 'table'
)
return null;
return (
<div
className={blockSelectionVariants({
active: isBlockSelected && !isDragging,
})}
data-slot="block-selection"
/>
);
}

View file

@ -0,0 +1,13 @@
import * as React from 'react';
import { type SlateElementProps, SlateElement } from 'platejs/static';
export function BlockquoteElementStatic(props: SlateElementProps) {
return (
<SlateElement
as="blockquote"
className="my-1 border-l-2 pl-6 italic"
{...props}
/>
);
}

View file

@ -0,0 +1,13 @@
'use client';
import { type PlateElementProps, PlateElement } from 'platejs/react';
export function BlockquoteElement(props: PlateElementProps) {
return (
<PlateElement
as="blockquote"
className="my-1 border-l-2 pl-6 italic"
{...props}
/>
);
}

View file

@ -0,0 +1,83 @@
'use client';
import * as React from 'react';
import type { TCalloutElement } from 'platejs';
import { CalloutPlugin } from '@platejs/callout/react';
import { cva } from 'class-variance-authority';
import { type PlateElementProps, PlateElement, useEditorPlugin } from 'platejs/react';
import { cn } from '@/lib/utils';
const calloutVariants = cva(
'my-1 flex w-full items-start gap-2 rounded-lg border p-4',
{
defaultVariants: {
variant: 'info',
},
variants: {
variant: {
info: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/50',
warning: 'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950/50',
error: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50',
success: 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50',
note: 'border-muted bg-muted/50',
tip: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-950/50',
},
},
}
);
const calloutIcons: Record<string, string> = {
info: '💡',
warning: '⚠️',
error: '🚨',
success: '✅',
note: '📝',
tip: '💜',
};
const variantCycle = ['info', 'warning', 'error', 'success', 'note', 'tip'] as const;
export function CalloutElement({
children,
...props
}: PlateElementProps<TCalloutElement>) {
const { editor } = useEditorPlugin(CalloutPlugin);
const element = props.element;
const variant = element.variant || 'info';
const icon = element.icon || calloutIcons[variant] || '💡';
const cycleVariant = React.useCallback(() => {
const currentIndex = variantCycle.indexOf(variant as (typeof variantCycle)[number]);
const nextIndex = (currentIndex + 1) % variantCycle.length;
const nextVariant = variantCycle[nextIndex];
editor.tf.setNodes(
{
variant: nextVariant,
icon: calloutIcons[nextVariant],
},
{ at: props.path }
);
}, [editor, variant, props.path]);
return (
<PlateElement
{...props}
className={cn(calloutVariants({ variant: variant as any }), props.className)}
>
<button
className="mt-0.5 shrink-0 cursor-pointer select-none text-lg leading-none"
contentEditable={false}
onClick={cycleVariant}
type="button"
aria-label="Change callout type"
>
{icon}
</button>
<div className="min-w-0 flex-1">{children}</div>
</PlateElement>
);
}

View file

@ -0,0 +1,161 @@
import * as React from 'react';
import type { TCodeBlockElement } from 'platejs';
import {
type SlateElementProps,
type SlateLeafProps,
SlateElement,
SlateLeaf,
} from 'platejs/static';
export function CodeBlockElementStatic(
props: SlateElementProps<TCodeBlockElement>
) {
return (
<SlateElement
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
{...props}
>
<div className="relative rounded-md bg-muted/50">
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
<code>{props.children}</code>
</pre>
</div>
</SlateElement>
);
}
export function CodeLineElementStatic(props: SlateElementProps) {
return <SlateElement {...props} />;
}
export function CodeSyntaxLeafStatic(props: SlateLeafProps) {
const tokenClassName = props.leaf.className as string;
return <SlateLeaf className={tokenClassName} {...props} />;
}
/**
* DOCX-compatible code block components.
* Uses inline styles for proper rendering in Word documents.
*/
export function CodeBlockElementDocx(
props: SlateElementProps<TCodeBlockElement>
) {
return (
<SlateElement {...props}>
<div
style={{
backgroundColor: '#f5f5f5',
border: '1px solid #e0e0e0',
margin: '8pt 0',
padding: '12pt',
}}
>
{props.children}
</div>
</SlateElement>
);
}
export function CodeLineElementDocx(props: SlateElementProps) {
return (
<SlateElement
{...props}
as="p"
style={{
fontFamily: "'Courier New', Consolas, monospace",
fontSize: '10pt',
margin: 0,
padding: 0,
}}
/>
);
}
// Syntax highlighting color map for common token types
const syntaxColors: Record<string, string> = {
'hljs-addition': '#22863a',
'hljs-attr': '#005cc5',
'hljs-attribute': '#005cc5',
'hljs-built_in': '#e36209',
'hljs-bullet': '#735c0f',
'hljs-comment': '#6a737d',
'hljs-deletion': '#b31d28',
'hljs-doctag': '#d73a49',
'hljs-emphasis': '#24292e',
'hljs-formula': '#6a737d',
'hljs-keyword': '#d73a49',
'hljs-literal': '#005cc5',
'hljs-meta': '#005cc5',
'hljs-name': '#22863a',
'hljs-number': '#005cc5',
'hljs-operator': '#005cc5',
'hljs-quote': '#22863a',
'hljs-regexp': '#032f62',
'hljs-section': '#005cc5',
'hljs-selector-attr': '#005cc5',
'hljs-selector-class': '#005cc5',
'hljs-selector-id': '#005cc5',
'hljs-selector-pseudo': '#22863a',
'hljs-selector-tag': '#22863a',
'hljs-string': '#032f62',
'hljs-strong': '#24292e',
'hljs-symbol': '#e36209',
'hljs-template-tag': '#d73a49',
'hljs-template-variable': '#d73a49',
'hljs-title': '#6f42c1',
'hljs-type': '#d73a49',
'hljs-variable': '#005cc5',
};
// Convert regular spaces to non-breaking spaces to preserve indentation in Word
const preserveSpaces = (text: string): string => {
// Replace regular spaces with non-breaking spaces
return text.replace(/ /g, '\u00A0');
};
export function CodeSyntaxLeafDocx(props: SlateLeafProps) {
const tokenClassName = props.leaf.className as string;
// Extract color from className
let color: string | undefined;
let fontWeight: string | undefined;
let fontStyle: string | undefined;
if (tokenClassName) {
const classes = tokenClassName.split(' ');
for (const cls of classes) {
if (syntaxColors[cls]) {
color = syntaxColors[cls];
}
if (cls === 'hljs-strong' || cls === 'hljs-section') {
fontWeight = 'bold';
}
if (cls === 'hljs-emphasis') {
fontStyle = 'italic';
}
}
}
// Get the text content and preserve spaces
const text = props.leaf.text as string;
const preservedText = preserveSpaces(text);
return (
<span
data-slate-leaf="true"
style={{
color,
fontFamily: "'Courier New', Consolas, monospace",
fontSize: '10pt',
fontStyle,
fontWeight,
}}
>
{preservedText}
</span>
);
}

View file

@ -0,0 +1,289 @@
'use client';
import * as React from 'react';
import { formatCodeBlock, isLangSupported } from '@platejs/code-block';
import { BracesIcon, Check, CheckIcon, CopyIcon } from 'lucide-react';
import { type TCodeBlockElement, type TCodeSyntaxLeaf, NodeApi } from 'platejs';
import {
type PlateElementProps,
type PlateLeafProps,
PlateElement,
PlateLeaf,
} from 'platejs/react';
import { useEditorRef, useElement, useReadOnly } from 'platejs/react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export function CodeBlockElement(props: PlateElementProps<TCodeBlockElement>) {
const { editor, element } = props;
return (
<PlateElement
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
{...props}
>
<div className="relative rounded-md bg-muted/50">
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
<code>{props.children}</code>
</pre>
<div
className="absolute top-1 right-1 z-10 flex select-none gap-0.5"
contentEditable={false}
>
{isLangSupported(element.lang) && (
<Button
size="icon"
variant="ghost"
className="size-6 text-xs"
onClick={() => formatCodeBlock(editor, { element })}
title="Format code"
>
<BracesIcon className="!size-3.5 text-muted-foreground" />
</Button>
)}
<CodeBlockCombobox />
<CopyButton
size="icon"
variant="ghost"
className="size-6 gap-1 text-muted-foreground text-xs"
value={() => NodeApi.string(element)}
/>
</div>
</div>
</PlateElement>
);
}
function CodeBlockCombobox() {
const [open, setOpen] = React.useState(false);
const readOnly = useReadOnly();
const editor = useEditorRef();
const element = useElement<TCodeBlockElement>();
const value = element.lang || 'plaintext';
const [searchValue, setSearchValue] = React.useState('');
const items = React.useMemo(
() =>
languages.filter(
(language) =>
!searchValue ||
language.label.toLowerCase().includes(searchValue.toLowerCase())
),
[searchValue]
);
if (readOnly) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 select-none justify-between gap-1 px-2 text-muted-foreground text-xs"
aria-expanded={open}
role="combobox"
>
{languages.find((language) => language.value === value)?.label ??
'Plain Text'}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[200px] p-0"
onCloseAutoFocus={() => setSearchValue('')}
>
<Command shouldFilter={false}>
<CommandInput
className="h-9"
value={searchValue}
onValueChange={(value) => setSearchValue(value)}
placeholder="Search language..."
/>
<CommandEmpty>No language found.</CommandEmpty>
<CommandList className="h-[344px] overflow-y-auto">
<CommandGroup>
{items.map((language) => (
<CommandItem
key={language.label}
className="cursor-pointer"
value={language.value}
onSelect={(value) => {
editor.tf.setNodes<TCodeBlockElement>(
{ lang: value },
{ at: element }
);
setSearchValue(value);
setOpen(false);
}}
>
<Check
className={cn(
value === language.value ? 'opacity-100' : 'opacity-0'
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function CopyButton({
value,
...props
}: { value: (() => string) | string } & Omit<
React.ComponentProps<typeof Button>,
'value'
>) {
const [hasCopied, setHasCopied] = React.useState(false);
React.useEffect(() => {
setTimeout(() => {
setHasCopied(false);
}, 2000);
}, [hasCopied]);
return (
<Button
onClick={() => {
void navigator.clipboard.writeText(
typeof value === 'function' ? value() : value
);
setHasCopied(true);
}}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? (
<CheckIcon className="!size-3" />
) : (
<CopyIcon className="!size-3" />
)}
</Button>
);
}
export function CodeLineElement(props: PlateElementProps) {
return <PlateElement {...props} />;
}
export function CodeSyntaxLeaf(props: PlateLeafProps<TCodeSyntaxLeaf>) {
const tokenClassName = props.leaf.className as string;
return <PlateLeaf className={tokenClassName} {...props} />;
}
const languages: { label: string; value: string }[] = [
{ label: 'Auto', value: 'auto' },
{ label: 'Plain Text', value: 'plaintext' },
{ label: 'ABAP', value: 'abap' },
{ label: 'Agda', value: 'agda' },
{ label: 'Arduino', value: 'arduino' },
{ label: 'ASCII Art', value: 'ascii' },
{ label: 'Assembly', value: 'x86asm' },
{ label: 'Bash', value: 'bash' },
{ label: 'BASIC', value: 'basic' },
{ label: 'BNF', value: 'bnf' },
{ label: 'C', value: 'c' },
{ label: 'C#', value: 'csharp' },
{ label: 'C++', value: 'cpp' },
{ label: 'Clojure', value: 'clojure' },
{ label: 'CoffeeScript', value: 'coffeescript' },
{ label: 'Coq', value: 'coq' },
{ label: 'CSS', value: 'css' },
{ label: 'Dart', value: 'dart' },
{ label: 'Dhall', value: 'dhall' },
{ label: 'Diff', value: 'diff' },
{ label: 'Docker', value: 'dockerfile' },
{ label: 'EBNF', value: 'ebnf' },
{ label: 'Elixir', value: 'elixir' },
{ label: 'Elm', value: 'elm' },
{ label: 'Erlang', value: 'erlang' },
{ label: 'F#', value: 'fsharp' },
{ label: 'Flow', value: 'flow' },
{ label: 'Fortran', value: 'fortran' },
{ label: 'Gherkin', value: 'gherkin' },
{ label: 'GLSL', value: 'glsl' },
{ label: 'Go', value: 'go' },
{ label: 'GraphQL', value: 'graphql' },
{ label: 'Groovy', value: 'groovy' },
{ label: 'Haskell', value: 'haskell' },
{ label: 'HCL', value: 'hcl' },
{ label: 'HTML', value: 'html' },
{ label: 'Idris', value: 'idris' },
{ label: 'Java', value: 'java' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'JSON', value: 'json' },
{ label: 'Julia', value: 'julia' },
{ label: 'Kotlin', value: 'kotlin' },
{ label: 'LaTeX', value: 'latex' },
{ label: 'Less', value: 'less' },
{ label: 'Lisp', value: 'lisp' },
{ label: 'LiveScript', value: 'livescript' },
{ label: 'LLVM IR', value: 'llvm' },
{ label: 'Lua', value: 'lua' },
{ label: 'Makefile', value: 'makefile' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'Markup', value: 'markup' },
{ label: 'MATLAB', value: 'matlab' },
{ label: 'Mathematica', value: 'mathematica' },
{ label: 'Mermaid', value: 'mermaid' },
{ label: 'Nix', value: 'nix' },
{ label: 'Notion Formula', value: 'notion' },
{ label: 'Objective-C', value: 'objectivec' },
{ label: 'OCaml', value: 'ocaml' },
{ label: 'Pascal', value: 'pascal' },
{ label: 'Perl', value: 'perl' },
{ label: 'PHP', value: 'php' },
{ label: 'PowerShell', value: 'powershell' },
{ label: 'Prolog', value: 'prolog' },
{ label: 'Protocol Buffers', value: 'protobuf' },
{ label: 'PureScript', value: 'purescript' },
{ label: 'Python', value: 'python' },
{ label: 'R', value: 'r' },
{ label: 'Racket', value: 'racket' },
{ label: 'Reason', value: 'reasonml' },
{ label: 'Ruby', value: 'ruby' },
{ label: 'Rust', value: 'rust' },
{ label: 'Sass', value: 'scss' },
{ label: 'Scala', value: 'scala' },
{ label: 'Scheme', value: 'scheme' },
{ label: 'SCSS', value: 'scss' },
{ label: 'Shell', value: 'shell' },
{ label: 'Smalltalk', value: 'smalltalk' },
{ label: 'Solidity', value: 'solidity' },
{ label: 'SQL', value: 'sql' },
{ label: 'Swift', value: 'swift' },
{ label: 'TOML', value: 'toml' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'VB.Net', value: 'vbnet' },
{ label: 'Verilog', value: 'verilog' },
{ label: 'VHDL', value: 'vhdl' },
{ label: 'Visual Basic', value: 'vbnet' },
{ label: 'WebAssembly', value: 'wasm' },
{ label: 'XML', value: 'xml' },
{ label: 'YAML', value: 'yaml' },
];

View file

@ -0,0 +1,17 @@
import * as React from 'react';
import type { SlateLeafProps } from 'platejs/static';
import { SlateLeaf } from 'platejs/static';
export function CodeLeafStatic(props: SlateLeafProps) {
return (
<SlateLeaf
{...props}
as="code"
className="whitespace-pre-wrap rounded-md bg-muted px-[0.3em] py-[0.2em] font-mono text-sm"
>
{props.children}
</SlateLeaf>
);
}

View file

@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import type { PlateLeafProps } from 'platejs/react';
import { PlateLeaf } from 'platejs/react';
export function CodeLeaf(props: PlateLeafProps) {
return (
<PlateLeaf
{...props}
as="code"
className="whitespace-pre-wrap rounded-md bg-muted px-[0.3em] py-[0.2em] font-mono text-sm"
>
{props.children}
</PlateLeaf>
);
}

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { type PlateStaticProps, PlateStatic } from 'platejs/static';
import { cn } from '@/lib/utils';
export const editorVariants = cva(
cn(
'group/editor',
'relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words',
'rounded-md ring-offset-background focus-visible:outline-none',
'placeholder:text-muted-foreground/80 **:data-slate-placeholder:top-[auto_!important] **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!',
'[&_strong]:font-bold'
),
{
defaultVariants: {
variant: 'none',
},
variants: {
disabled: {
true: 'cursor-not-allowed opacity-50',
},
focused: {
true: 'ring-2 ring-ring ring-offset-2',
},
variant: {
ai: 'w-full px-0 text-base md:text-sm',
aiChat:
'max-h-[min(70vh,320px)] w-full overflow-y-auto px-5 py-3 text-base md:text-sm',
default:
'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24',
none: '',
select: 'px-3 py-2 text-base data-readonly:w-fit',
},
},
}
);
export function EditorStatic({
className,
variant,
...props
}: PlateStaticProps & VariantProps<typeof editorVariants>) {
return (
<PlateStatic
className={cn(editorVariants({ variant }), className)}
{...props}
/>
);
}

View file

@ -0,0 +1,132 @@
'use client';
import * as React from 'react';
import type { VariantProps } from 'class-variance-authority';
import type { PlateContentProps, PlateViewProps } from 'platejs/react';
import { cva } from 'class-variance-authority';
import { PlateContainer, PlateContent, PlateView } from 'platejs/react';
import { cn } from '@/lib/utils';
const editorContainerVariants = cva(
'relative w-full cursor-text select-text overflow-y-auto caret-primary selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15',
{
defaultVariants: {
variant: 'default',
},
variants: {
variant: {
comment: cn(
'flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm',
'rounded-md border-[1.5px] border-transparent bg-transparent',
'has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30',
'has-aria-disabled:border-input has-aria-disabled:bg-muted'
),
default: 'h-full',
demo: 'h-[650px]',
select: cn(
'group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
'has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]'
),
},
},
}
);
export function EditorContainer({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof editorContainerVariants>) {
return (
<PlateContainer
className={cn(
'ignore-click-outside/toolbar',
editorContainerVariants({ variant }),
className
)}
{...props}
/>
);
}
const editorVariants = cva(
cn(
'group/editor',
'relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words',
'rounded-md ring-offset-background focus-visible:outline-none',
'**:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 placeholder:text-muted-foreground/80 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!',
'[&_strong]:font-bold'
),
{
defaultVariants: {
variant: 'default',
},
variants: {
disabled: {
true: 'cursor-not-allowed opacity-50',
},
focused: {
true: 'ring-2 ring-ring ring-offset-2',
},
variant: {
ai: 'w-full px-0 text-base md:text-sm',
aiChat:
'max-h-[min(70vh,320px)] w-full overflow-y-auto px-3 py-2 text-base md:text-sm',
comment: cn('rounded-none border-none bg-transparent text-sm'),
default:
'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24',
none: '',
select: 'px-3 py-2 text-base data-readonly:w-fit',
},
},
}
);
export type EditorProps = PlateContentProps &
VariantProps<typeof editorVariants>;
export const Editor = ({
className,
disabled,
focused,
variant,
ref,
...props
}: EditorProps & { ref?: React.RefObject<HTMLDivElement | null> }) => (
<PlateContent
ref={ref}
className={cn(
editorVariants({
disabled,
focused,
variant,
}),
className
)}
disabled={disabled}
disableDefaultStyles
{...props}
/>
);
Editor.displayName = 'Editor';
export function EditorView({
className,
variant,
...props
}: PlateViewProps & VariantProps<typeof editorVariants>) {
return (
<PlateView
{...props}
className={cn(editorVariants({ variant }), className)}
/>
);
}
EditorView.displayName = 'EditorView';

View file

@ -0,0 +1,183 @@
'use client';
import * as React from 'react';
import type { TEquationElement } from 'platejs';
import {
useEquationElement,
useEquationInput,
} from '@platejs/math/react';
import { RadicalIcon } from 'lucide-react';
import { type PlateElementProps, PlateElement, useSelected } from 'platejs/react';
import { cn } from '@/lib/utils';
export function EquationElement({
children,
...props
}: PlateElementProps<TEquationElement>) {
const element = props.element;
const selected = useSelected();
const katexRef = React.useRef<HTMLDivElement | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
useEquationElement({
element,
katexRef,
options: {
displayMode: true,
throwOnError: false,
},
});
const { props: inputProps, ref: inputRef, onDismiss, onSubmit } = useEquationInput({
isInline: false,
open: isEditing,
onClose: () => setIsEditing(false),
});
return (
<PlateElement
{...props}
className={cn(
'my-3 rounded-md py-2',
selected && 'ring-2 ring-ring ring-offset-2',
props.className
)}
>
<div
className="flex cursor-pointer items-center justify-center"
contentEditable={false}
onDoubleClick={() => setIsEditing(true)}
>
{element.texExpression ? (
<div ref={katexRef} className="text-center" />
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RadicalIcon className="size-4" />
<span>Add an equation</span>
</div>
)}
</div>
{isEditing && (
<div
className="mt-2 rounded-md border bg-muted/50 p-2"
contentEditable={false}
>
<textarea
ref={inputRef}
className="w-full resize-none rounded border-none bg-transparent p-2 font-mono text-sm outline-none"
placeholder="E = mc^2"
rows={3}
{...inputProps}
/>
<div className="mt-1 flex justify-end gap-1">
<button
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
onClick={onDismiss}
type="button"
>
Cancel
</button>
<button
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
onClick={onSubmit}
type="button"
>
Done
</button>
</div>
</div>
)}
{children}
</PlateElement>
);
}
export function InlineEquationElement({
children,
...props
}: PlateElementProps<TEquationElement>) {
const element = props.element;
const selected = useSelected();
const katexRef = React.useRef<HTMLDivElement | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
useEquationElement({
element,
katexRef,
options: {
displayMode: false,
throwOnError: false,
},
});
const { props: inputProps, ref: inputRef, onDismiss, onSubmit } = useEquationInput({
isInline: true,
open: isEditing,
onClose: () => setIsEditing(false),
});
return (
<PlateElement
{...props}
as="span"
className={cn(
'inline rounded-sm px-0.5',
selected && 'bg-brand/15',
props.className
)}
>
<span
className="cursor-pointer"
contentEditable={false}
onDoubleClick={() => setIsEditing(true)}
>
{element.texExpression ? (
<span ref={katexRef} />
) : (
<span className="text-sm text-muted-foreground">
<RadicalIcon className="inline size-3.5" />
</span>
)}
</span>
{isEditing && (
<span
className="absolute z-50 mt-1 rounded-md border bg-popover p-2 shadow-md"
contentEditable={false}
>
<textarea
ref={inputRef}
className="w-48 resize-none rounded border-none bg-transparent p-1 font-mono text-sm outline-none"
placeholder="x^2"
rows={1}
{...inputProps}
/>
<span className="mt-1 flex justify-end gap-1">
<button
className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent"
onClick={onDismiss}
type="button"
>
Cancel
</button>
<button
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90"
onClick={onSubmit}
type="button"
>
Done
</button>
</span>
</span>
)}
{children}
</PlateElement>
);
}

View file

@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import { insertInlineEquation } from '@platejs/math';
import { RadicalIcon } from 'lucide-react';
import { useEditorRef } from 'platejs/react';
import { ToolbarButton } from './toolbar';
export function InlineEquationToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const editor = useEditorRef();
return (
<ToolbarButton
{...props}
onClick={() => {
insertInlineEquation(editor);
}}
tooltip="Mark as equation"
>
<RadicalIcon />
</ToolbarButton>
);
}

View file

@ -0,0 +1,65 @@
'use client';
import * as React from 'react';
import {
BoldIcon,
Code2Icon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
} from 'lucide-react';
import { KEYS } from 'platejs';
import { useEditorReadOnly } from 'platejs/react';
import { LinkToolbarButton } from './link-toolbar-button';
import { MarkToolbarButton } from './mark-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
import { ToolbarGroup } from './toolbar';
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
export function FloatingToolbarButtons() {
const readOnly = useEditorReadOnly();
if (readOnly) return null;
return (
<>
<ToolbarGroup>
<TurnIntoToolbarButton />
<MarkToolbarButton nodeType={KEYS.bold} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic} tooltip="Italic (⌘+I)">
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton
nodeType={KEYS.underline}
tooltip="Underline (⌘+U)"
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton
nodeType={KEYS.strikethrough}
tooltip="Strikethrough (⌘+⇧+M)"
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code} tooltip="Code (⌘+E)">
<Code2Icon />
</MarkToolbarButton>
<LinkToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MoreToolbarButton />
</ToolbarGroup>
</>
);
}

View file

@ -0,0 +1,86 @@
'use client';
import * as React from 'react';
import {
type FloatingToolbarState,
flip,
offset,
useFloatingToolbar,
useFloatingToolbarState,
} from '@platejs/floating';
import { useComposedRef } from '@udecode/cn';
import { KEYS } from 'platejs';
import {
useEditorId,
useEventEditorValue,
usePluginOption,
} from 'platejs/react';
import { cn } from '@/lib/utils';
import { Toolbar } from './toolbar';
export function FloatingToolbar({
children,
className,
state,
...props
}: React.ComponentProps<typeof Toolbar> & {
state?: FloatingToolbarState;
}) {
const editorId = useEditorId();
const focusedEditorId = useEventEditorValue('focus');
const isFloatingLinkOpen = !!usePluginOption({ key: KEYS.link }, 'mode');
const floatingToolbarState = useFloatingToolbarState({
editorId,
focusedEditorId,
hideToolbar: isFloatingLinkOpen,
...state,
floatingOptions: {
middleware: [
offset(12),
flip({
fallbackPlacements: [
'top-start',
'top-end',
'bottom-start',
'bottom-end',
],
padding: 12,
}),
],
placement: 'top',
...state?.floatingOptions,
},
});
const {
clickOutsideRef,
hidden,
props: rootProps,
ref: floatingRef,
} = useFloatingToolbar(floatingToolbarState);
const ref = useComposedRef<HTMLDivElement>(props.ref, floatingRef);
if (hidden) return null;
return (
<div ref={clickOutsideRef}>
<Toolbar
{...props}
{...rootProps}
ref={ref}
className={cn(
'scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden',
'max-w-[80vw]',
className
)}
>
{children}
</Toolbar>
</div>
);
}

View file

@ -0,0 +1,72 @@
import * as React from 'react';
import type { SlateElementProps } from 'platejs/static';
import { type VariantProps, cva } from 'class-variance-authority';
import { SlateElement } from 'platejs/static';
const headingVariants = cva('relative mb-1', {
variants: {
variant: {
h1: 'mt-[1.6em] pb-1 font-bold font-heading text-4xl',
h2: 'mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight',
h3: 'mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight',
h4: 'mt-[0.75em] font-heading font-semibold text-lg tracking-tight',
h5: 'mt-[0.75em] font-semibold text-lg tracking-tight',
h6: 'mt-[0.75em] font-semibold text-base tracking-tight',
},
},
});
export function HeadingElementStatic({
variant = 'h1',
...props
}: SlateElementProps & VariantProps<typeof headingVariants>) {
const id = props.element.id as string | undefined;
return (
<SlateElement
as={variant!}
className={headingVariants({ variant })}
{...props}
>
{/* Bookmark anchor for DOCX TOC internal links */}
{id && <span id={id} />}
{props.children}
</SlateElement>
);
}
export function H1ElementStatic(props: SlateElementProps) {
return <HeadingElementStatic variant="h1" {...props} />;
}
export function H2ElementStatic(
props: React.ComponentProps<typeof HeadingElementStatic>
) {
return <HeadingElementStatic variant="h2" {...props} />;
}
export function H3ElementStatic(
props: React.ComponentProps<typeof HeadingElementStatic>
) {
return <HeadingElementStatic variant="h3" {...props} />;
}
export function H4ElementStatic(
props: React.ComponentProps<typeof HeadingElementStatic>
) {
return <HeadingElementStatic variant="h4" {...props} />;
}
export function H5ElementStatic(
props: React.ComponentProps<typeof HeadingElementStatic>
) {
return <HeadingElementStatic variant="h5" {...props} />;
}
export function H6ElementStatic(
props: React.ComponentProps<typeof HeadingElementStatic>
) {
return <HeadingElementStatic variant="h6" {...props} />;
}

View file

@ -0,0 +1,60 @@
'use client';
import * as React from 'react';
import type { PlateElementProps } from 'platejs/react';
import { type VariantProps, cva } from 'class-variance-authority';
import { PlateElement } from 'platejs/react';
const headingVariants = cva('relative mb-1', {
variants: {
variant: {
h1: 'mt-[1.6em] pb-1 font-bold font-heading text-4xl',
h2: 'mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight',
h3: 'mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight',
h4: 'mt-[0.75em] font-heading font-semibold text-lg tracking-tight',
h5: 'mt-[0.75em] font-semibold text-lg tracking-tight',
h6: 'mt-[0.75em] font-semibold text-base tracking-tight',
},
},
});
export function HeadingElement({
variant = 'h1',
...props
}: PlateElementProps & VariantProps<typeof headingVariants>) {
return (
<PlateElement
as={variant!}
className={headingVariants({ variant })}
{...props}
>
{props.children}
</PlateElement>
);
}
export function H1Element(props: PlateElementProps) {
return <HeadingElement variant="h1" {...props} />;
}
export function H2Element(props: PlateElementProps) {
return <HeadingElement variant="h2" {...props} />;
}
export function H3Element(props: PlateElementProps) {
return <HeadingElement variant="h3" {...props} />;
}
export function H4Element(props: PlateElementProps) {
return <HeadingElement variant="h4" {...props} />;
}
export function H5Element(props: PlateElementProps) {
return <HeadingElement variant="h5" {...props} />;
}
export function H6Element(props: PlateElementProps) {
return <HeadingElement variant="h6" {...props} />;
}

View file

@ -0,0 +1,13 @@
import * as React from 'react';
import type { SlateLeafProps } from 'platejs/static';
import { SlateLeaf } from 'platejs/static';
export function HighlightLeafStatic(props: SlateLeafProps) {
return (
<SlateLeaf {...props} as="mark" className="bg-highlight/30 text-inherit">
{props.children}
</SlateLeaf>
);
}

View file

@ -0,0 +1,15 @@
'use client';
import * as React from 'react';
import type { PlateLeafProps } from 'platejs/react';
import { PlateLeaf } from 'platejs/react';
export function HighlightLeaf(props: PlateLeafProps) {
return (
<PlateLeaf {...props} as="mark" className="bg-highlight/30 text-inherit">
{props.children}
</PlateLeaf>
);
}

View file

@ -0,0 +1,22 @@
import * as React from 'react';
import type { SlateElementProps } from 'platejs/static';
import { SlateElement } from 'platejs/static';
import { cn } from '@/lib/utils';
export function HrElementStatic(props: SlateElementProps) {
return (
<SlateElement {...props}>
<div className="cursor-text py-6" contentEditable={false}>
<hr
className={cn(
'h-0.5 rounded-sm border-none bg-muted bg-clip-content'
)}
/>
</div>
{props.children}
</SlateElement>
);
}

View file

@ -0,0 +1,35 @@
'use client';
import * as React from 'react';
import type { PlateElementProps } from 'platejs/react';
import {
PlateElement,
useFocused,
useReadOnly,
useSelected,
} from 'platejs/react';
import { cn } from '@/lib/utils';
export function HrElement(props: PlateElementProps) {
const readOnly = useReadOnly();
const selected = useSelected();
const focused = useFocused();
return (
<PlateElement {...props}>
<div className="py-6" contentEditable={false}>
<hr
className={cn(
'h-0.5 rounded-sm border-none bg-muted bg-clip-content',
selected && focused && 'ring-2 ring-ring ring-offset-2',
!readOnly && 'cursor-pointer'
)}
/>
</div>
{props.children}
</PlateElement>
);
}

View file

@ -0,0 +1,17 @@
import * as React from 'react';
import type { SlateLeafProps } from 'platejs/static';
import { SlateLeaf } from 'platejs/static';
export function KbdLeafStatic(props: SlateLeafProps) {
return (
<SlateLeaf
{...props}
as="kbd"
className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-sm shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(248,_249,_250)_0px_1px_5px_0px_inset,_rgb(193,_200,_205)_0px_0px_0px_0.5px,_rgb(193,_200,_205)_0px_2px_1px_-1px,_rgb(193,_200,_205)_0px_1px_0px_0px] dark:shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(26,_29,_30)_0px_1px_5px_0px_inset,_rgb(76,_81,_85)_0px_0px_0px_0.5px,_rgb(76,_81,_85)_0px_2px_1px_-1px,_rgb(76,_81,_85)_0px_1px_0px_0px]"
>
{props.children}
</SlateLeaf>
);
}

View file

@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import type { PlateLeafProps } from 'platejs/react';
import { PlateLeaf } from 'platejs/react';
export function KbdLeaf(props: PlateLeafProps) {
return (
<PlateLeaf
{...props}
as="kbd"
className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-sm shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(248,_249,_250)_0px_1px_5px_0px_inset,_rgb(193,_200,_205)_0px_0px_0px_0.5px,_rgb(193,_200,_205)_0px_2px_1px_-1px,_rgb(193,_200,_205)_0px_1px_0px_0px] dark:shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(26,_29,_30)_0px_1px_5px_0px_inset,_rgb(76,_81,_85)_0px_0px_0px_0.5px,_rgb(76,_81,_85)_0px_2px_1px_-1px,_rgb(76,_81,_85)_0px_1px_0px_0px]"
>
{props.children}
</PlateLeaf>
);
}

View file

@ -0,0 +1,23 @@
import * as React from 'react';
import type { TLinkElement } from 'platejs';
import type { SlateElementProps } from 'platejs/static';
import { getLinkAttributes } from '@platejs/link';
import { SlateElement } from 'platejs/static';
export function LinkElementStatic(props: SlateElementProps<TLinkElement>) {
return (
<SlateElement
{...props}
as="a"
className="font-medium text-primary underline decoration-primary underline-offset-4"
attributes={{
...props.attributes,
...getLinkAttributes(props.editor, props.element),
}}
>
{props.children}
</SlateElement>
);
}

View file

@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import type { TLinkElement } from 'platejs';
import type { PlateElementProps } from 'platejs/react';
import { getLinkAttributes } from '@platejs/link';
import { PlateElement } from 'platejs/react';
import { cn } from '@/lib/utils';
export function LinkElement(props: PlateElementProps<TLinkElement>) {
return (
<PlateElement
{...props}
as="a"
className={cn(
'font-medium text-primary underline decoration-primary underline-offset-4'
)}
attributes={{
...props.attributes,
...getLinkAttributes(props.editor, props.element),
onMouseOver: (e) => {
e.stopPropagation();
},
}}
>
{props.children}
</PlateElement>
);
}

View file

@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import {
useLinkToolbarButton,
useLinkToolbarButtonState,
} from '@platejs/link/react';
import { Link } from 'lucide-react';
import { ToolbarButton } from './toolbar';
export function LinkToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const state = useLinkToolbarButtonState();
const { props: buttonProps } = useLinkToolbarButton(state);
return (
<ToolbarButton {...props} {...buttonProps} data-plate-focus tooltip="Link">
<Link />
</ToolbarButton>
);
}

View file

@ -0,0 +1,208 @@
'use client';
import * as React from 'react';
import type { TLinkElement } from 'platejs';
import {
type UseVirtualFloatingOptions,
flip,
offset,
} from '@platejs/floating';
import { getLinkAttributes } from '@platejs/link';
import {
type LinkFloatingToolbarState,
FloatingLinkUrlInput,
useFloatingLinkEdit,
useFloatingLinkEditState,
useFloatingLinkInsert,
useFloatingLinkInsertState,
} from '@platejs/link/react';
import { cva } from 'class-variance-authority';
import { ExternalLink, Link, Text, Unlink } from 'lucide-react';
import { KEYS } from 'platejs';
import {
useEditorRef,
useEditorSelection,
useFormInputProps,
usePluginOption,
} from 'platejs/react';
import { buttonVariants } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
const popoverVariants = cva(
'z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden'
);
const inputVariants = cva(
'flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-transparent md:text-sm'
);
export function LinkFloatingToolbar({
state,
}: {
state?: LinkFloatingToolbarState;
}) {
const activeCommentId = usePluginOption({ key: KEYS.comment }, 'activeId');
const activeSuggestionId = usePluginOption(
{ key: KEYS.suggestion },
'activeId'
);
const floatingOptions: UseVirtualFloatingOptions = React.useMemo(
() => ({
middleware: [
offset(8),
flip({
fallbackPlacements: ['bottom-end', 'top-start', 'top-end'],
padding: 12,
}),
],
placement:
activeSuggestionId || activeCommentId ? 'top-start' : 'bottom-start',
}),
[activeCommentId, activeSuggestionId]
);
const insertState = useFloatingLinkInsertState({
...state,
floatingOptions: {
...floatingOptions,
...state?.floatingOptions,
},
});
const {
hidden,
props: insertProps,
ref: insertRef,
textInputProps,
} = useFloatingLinkInsert(insertState);
const editState = useFloatingLinkEditState({
...state,
floatingOptions: {
...floatingOptions,
...state?.floatingOptions,
},
});
const {
editButtonProps,
props: editProps,
ref: editRef,
unlinkButtonProps,
} = useFloatingLinkEdit(editState);
const inputProps = useFormInputProps({
preventDefaultOnEnterKeydown: true,
});
if (hidden) return null;
const input = (
<div className="flex w-[330px] flex-col" {...inputProps}>
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Link className="size-4" />
</div>
<FloatingLinkUrlInput
className={inputVariants()}
placeholder="Paste link"
data-plate-focus
/>
</div>
<Separator className="my-1" />
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Text className="size-4" />
</div>
<input
className={inputVariants()}
placeholder="Text to display"
data-plate-focus
{...textInputProps}
/>
</div>
</div>
);
const editContent = editState.isEditing ? (
input
) : (
<div className="box-content flex items-center">
<button
className={buttonVariants({ size: 'sm', variant: 'ghost' })}
type="button"
{...editButtonProps}
>
Edit link
</button>
<Separator orientation="vertical" />
<LinkOpenButton />
<Separator orientation="vertical" />
<button
className={buttonVariants({
size: 'sm',
variant: 'ghost',
})}
type="button"
{...unlinkButtonProps}
>
<Unlink width={18} />
</button>
</div>
);
return (
<>
<div ref={insertRef} className={popoverVariants()} {...insertProps}>
{input}
</div>
<div ref={editRef} className={popoverVariants()} {...editProps}>
{editContent}
</div>
</>
);
}
function LinkOpenButton() {
const editor = useEditorRef();
const selection = useEditorSelection();
const attributes = React.useMemo(
() => {
const entry = editor.api.node<TLinkElement>({
match: { type: editor.getType(KEYS.link) },
});
if (!entry) {
return {};
}
const [element] = entry;
return getLinkAttributes(editor, element);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[editor, selection]
);
return (
<a
{...attributes}
className={buttonVariants({
size: 'sm',
variant: 'ghost',
})}
onMouseOver={(e) => {
e.stopPropagation();
}}
aria-label="Open link in a new tab"
target="_blank"
>
<ExternalLink width={18} />
</a>
);
}

View file

@ -0,0 +1,105 @@
'use client';
import * as React from 'react';
import type { PlateElementProps } from 'platejs/react';
import {
useTodoListElement,
useTodoListElementState,
} from '@platejs/list-classic/react';
import { type VariantProps, cva } from 'class-variance-authority';
import { PlateElement } from 'platejs/react';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
const listVariants = cva('m-0 py-1 ps-6', {
variants: {
variant: {
ol: 'list-decimal',
ul: 'list-disc [&_ul]:list-[circle] [&_ul_ul]:list-[square]',
},
},
});
export function ListElement({
variant,
...props
}: PlateElementProps & VariantProps<typeof listVariants>) {
return (
<PlateElement
as={variant!}
className={listVariants({ variant })}
{...props}
>
{props.children}
</PlateElement>
);
}
export function BulletedListElement(props: PlateElementProps) {
return <ListElement variant="ul" {...props} />;
}
export function NumberedListElement(props: PlateElementProps) {
return <ListElement variant="ol" {...props} />;
}
export function TaskListElement(props: PlateElementProps) {
return (
<PlateElement as="ul" className="m-0 list-none! py-1 ps-6" {...props}>
{props.children}
</PlateElement>
);
}
export function ListItemElement(props: PlateElementProps) {
const isTaskList = 'checked' in props.element;
if (isTaskList) {
return <TaskListItemElement {...props} />;
}
return <BaseListItemElement {...props} />;
}
export function BaseListItemElement(props: PlateElementProps) {
return (
<PlateElement as="li" {...props}>
{props.children}
</PlateElement>
);
}
export function TaskListItemElement(props: PlateElementProps) {
const { element } = props;
const state = useTodoListElementState({ element });
const { checkboxProps } = useTodoListElement(state);
const [firstChild, ...otherChildren] = React.Children.toArray(props.children);
return (
<BaseListItemElement {...props}>
<div
className={cn(
'flex items-stretch *:nth-[2]:flex-1 *:nth-[2]:focus:outline-none',
{
'*:nth-[2]:text-muted-foreground *:nth-[2]:line-through':
state.checked,
}
)}
>
<div
className="-ms-5 me-1.5 flex w-fit select-none items-start justify-center pt-[0.275em]"
contentEditable={false}
>
<Checkbox {...checkboxProps} />
</div>
{firstChild}
</div>
{otherChildren}
</BaseListItemElement>
);
}

View file

@ -0,0 +1,69 @@
'use client';
import * as React from 'react';
import { indentListItems, unindentListItems } from '@platejs/list-classic';
import {
useListToolbarButton,
useListToolbarButtonState,
} from '@platejs/list-classic/react';
import {
IndentIcon,
List,
ListOrdered,
ListTodo,
OutdentIcon,
} from 'lucide-react';
import { KEYS } from 'platejs';
import { useEditorRef } from 'platejs/react';
import { ToolbarButton } from './toolbar';
const nodeTypeMap: Record<string, { icon: React.JSX.Element; label: string }> =
{
[KEYS.olClassic]: { icon: <ListOrdered />, label: 'Numbered List' },
[KEYS.taskList]: { icon: <ListTodo />, label: 'Task List' },
[KEYS.ulClassic]: { icon: <List />, label: 'Bulleted List' },
};
export function ListToolbarButton({
nodeType = KEYS.ulClassic,
...props
}: React.ComponentProps<typeof ToolbarButton> & {
nodeType?: string;
}) {
const state = useListToolbarButtonState({ nodeType });
const { props: buttonProps } = useListToolbarButton(state);
const { icon, label } = nodeTypeMap[nodeType] ?? nodeTypeMap[KEYS.ulClassic];
return (
<ToolbarButton {...props} {...buttonProps} tooltip={label}>
{icon}
</ToolbarButton>
);
}
export function IndentToolbarButton({
reverse = false,
...props
}: React.ComponentProps<typeof ToolbarButton> & {
reverse?: boolean;
}) {
const editor = useEditorRef();
return (
<ToolbarButton
{...props}
onClick={() => {
if (reverse) {
unindentListItems(editor);
} else {
indentListItems(editor);
}
}}
tooltip={reverse ? 'Outdent' : 'Indent'}
>
{reverse ? <OutdentIcon /> : <IndentIcon />}
</ToolbarButton>
);
}

View file

@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import { useMarkToolbarButton, useMarkToolbarButtonState } from 'platejs/react';
import { ToolbarButton } from './toolbar';
export function MarkToolbarButton({
clear,
nodeType,
...props
}: React.ComponentProps<typeof ToolbarButton> & {
nodeType: string;
clear?: string[] | string;
}) {
const state = useMarkToolbarButtonState({ clear, nodeType });
const { props: buttonProps } = useMarkToolbarButton(state);
return <ToolbarButton {...props} {...buttonProps} />;
}

View file

@ -0,0 +1,108 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import { insertInlineEquation } from '@platejs/math';
import {
InfoIcon,
KeyboardIcon,
MoreHorizontalIcon,
RadicalIcon,
SubscriptIcon,
SuperscriptIcon,
} from 'lucide-react';
import { KEYS } from 'platejs';
import { useEditorRef } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ToolbarButton } from './toolbar';
export function MoreToolbarButton(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">
<MoreHorizontalIcon />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="ignore-click-outside/toolbar flex max-h-[500px] min-w-[180px] flex-col overflow-y-auto"
align="start"
>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => {
editor.tf.toggleMark(KEYS.kbd);
editor.tf.collapse({ edge: 'end' });
editor.tf.focus();
}}
>
<KeyboardIcon />
Keyboard input
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
editor.tf.toggleMark(KEYS.sup, {
remove: KEYS.sub,
});
editor.tf.focus();
}}
>
<SuperscriptIcon />
Superscript
{/* (⌘+,) */}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
editor.tf.toggleMark(KEYS.sub, {
remove: KEYS.sup,
});
editor.tf.focus();
}}
>
<SubscriptIcon />
Subscript
{/* (⌘+.) */}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
insertInlineEquation(editor);
editor.tf.focus();
}}
>
<RadicalIcon />
Equation
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
editor.tf.insertNodes(
editor.api.create.block({ type: KEYS.callout }),
{ select: true }
);
editor.tf.focus();
}}
>
<InfoIcon />
Callout
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,15 @@
import * as React from 'react';
import type { SlateElementProps } from 'platejs/static';
import { SlateElement } from 'platejs/static';
import { cn } from '@/lib/utils';
export function ParagraphElementStatic(props: SlateElementProps) {
return (
<SlateElement {...props} className={cn('m-0 px-0 py-1')}>
{props.children}
</SlateElement>
);
}

View file

@ -0,0 +1,17 @@
'use client';
import * as React from 'react';
import type { PlateElementProps } from 'platejs/react';
import { PlateElement } from 'platejs/react';
import { cn } from '@/lib/utils';
export function ParagraphElement(props: PlateElementProps) {
return (
<PlateElement {...props} className={cn('m-0 px-0 py-1')}>
{props.children}
</PlateElement>
);
}

View file

@ -0,0 +1,89 @@
'use client';
import * as React from 'react';
import type { VariantProps } from 'class-variance-authority';
import {
type ResizeHandle as ResizeHandlePrimitive,
Resizable as ResizablePrimitive,
useResizeHandle,
useResizeHandleState,
} from '@platejs/resizable';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
export const mediaResizeHandleVariants = cva(
cn(
'top-0 flex w-6 select-none flex-col justify-center',
"after:flex after:h-16 after:w-[3px] after:rounded-[6px] after:bg-ring after:opacity-0 after:content-['_'] group-hover:after:opacity-100"
),
{
variants: {
direction: {
left: '-left-3 -ml-3 pl-3',
right: '-right-3 -mr-3 items-end pr-3',
},
},
}
);
const resizeHandleVariants = cva('absolute z-40', {
variants: {
direction: {
bottom: 'w-full cursor-row-resize',
left: 'h-full cursor-col-resize',
right: 'h-full cursor-col-resize',
top: 'w-full cursor-row-resize',
},
},
});
export function ResizeHandle({
className,
options,
...props
}: React.ComponentProps<typeof ResizeHandlePrimitive> &
VariantProps<typeof resizeHandleVariants>) {
const state = useResizeHandleState(options ?? {});
const resizeHandle = useResizeHandle(state);
if (state.readOnly) return null;
return (
<div
className={cn(
resizeHandleVariants({ direction: options?.direction }),
className
)}
data-resizing={state.isResizing}
{...resizeHandle.props}
{...props}
/>
);
}
const resizableVariants = cva('', {
variants: {
align: {
center: 'mx-auto',
left: 'mr-auto',
right: 'ml-auto',
},
},
});
export function Resizable({
align,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive> &
VariantProps<typeof resizableVariants>) {
return (
<ResizablePrimitive
{...props}
className={cn(resizableVariants({ align }), className)}
/>
);
}

View file

@ -0,0 +1,211 @@
'use client';
import * as React from 'react';
import type { PlateElementProps } from 'platejs/react';
import { SlashInputPlugin } from '@platejs/slash-command/react';
import {
ChevronRightIcon,
Code2Icon,
Columns2Icon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
InfoIcon,
ListIcon,
ListOrderedIcon,
MinusIcon,
PilcrowIcon,
QuoteIcon,
RadicalIcon,
SquareIcon,
TableIcon,
} from 'lucide-react';
import { KEYS } from 'platejs';
import { PlateElement, useEditorPlugin, useEditorRef } from 'platejs/react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { insertBlock, insertInlineElement } from '@/components/editor/transforms';
interface SlashCommandItem {
icon: React.ReactNode;
keywords: string[];
label: string;
onSelect: (editor: any) => void;
}
const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [
{
heading: 'Basic Blocks',
items: [
{
icon: <PilcrowIcon />,
keywords: ['paragraph', 'text', 'plain'],
label: 'Text',
onSelect: (editor) => insertBlock(editor, KEYS.p),
},
{
icon: <Heading1Icon />,
keywords: ['title', 'h1', 'heading'],
label: 'Heading 1',
onSelect: (editor) => insertBlock(editor, 'h1'),
},
{
icon: <Heading2Icon />,
keywords: ['subtitle', 'h2', 'heading'],
label: 'Heading 2',
onSelect: (editor) => insertBlock(editor, 'h2'),
},
{
icon: <Heading3Icon />,
keywords: ['subtitle', 'h3', 'heading'],
label: 'Heading 3',
onSelect: (editor) => insertBlock(editor, 'h3'),
},
{
icon: <QuoteIcon />,
keywords: ['citation', 'blockquote'],
label: 'Quote',
onSelect: (editor) => insertBlock(editor, KEYS.blockquote),
},
{
icon: <MinusIcon />,
keywords: ['divider', 'separator', 'line'],
label: 'Divider',
onSelect: (editor) => insertBlock(editor, KEYS.hr),
},
],
},
{
heading: 'Lists',
items: [
{
icon: <ListIcon />,
keywords: ['unordered', 'ul', 'bullet'],
label: 'Bulleted list',
onSelect: (editor) => insertBlock(editor, KEYS.ul),
},
{
icon: <ListOrderedIcon />,
keywords: ['ordered', 'ol', 'numbered'],
label: 'Numbered list',
onSelect: (editor) => insertBlock(editor, KEYS.ol),
},
{
icon: <SquareIcon />,
keywords: ['checklist', 'task', 'checkbox', 'todo'],
label: 'To-do list',
onSelect: (editor) => insertBlock(editor, KEYS.listTodo),
},
],
},
{
heading: 'Advanced',
items: [
{
icon: <TableIcon />,
keywords: ['table', 'grid'],
label: 'Table',
onSelect: (editor) => insertBlock(editor, KEYS.table),
},
{
icon: <FileCodeIcon />,
keywords: ['code', 'codeblock', 'snippet'],
label: 'Code block',
onSelect: (editor) => insertBlock(editor, KEYS.codeBlock),
},
{
icon: <InfoIcon />,
keywords: ['callout', 'note', 'info', 'warning', 'tip'],
label: 'Callout',
onSelect: (editor) => insertBlock(editor, KEYS.callout),
},
{
icon: <ChevronRightIcon />,
keywords: ['toggle', 'collapsible', 'expand'],
label: 'Toggle',
onSelect: (editor) => insertBlock(editor, KEYS.toggle),
},
{
icon: <RadicalIcon />,
keywords: ['equation', 'math', 'formula', 'latex'],
label: 'Equation',
onSelect: (editor) => insertInlineElement(editor, KEYS.equation),
},
],
},
{
heading: 'Inline',
items: [
{
icon: <Code2Icon />,
keywords: ['link', 'url', 'href'],
label: 'Link',
onSelect: (editor) => insertInlineElement(editor, KEYS.link),
},
],
},
];
export function SlashInputElement({
children,
...props
}: PlateElementProps) {
const { editor, setOption } = useEditorPlugin(SlashInputPlugin);
return (
<PlateElement {...props} as="span">
<Command
className="relative z-50 min-w-[280px] overflow-hidden rounded-lg border bg-popover shadow-md"
shouldFilter={true}
>
<CommandInput
className="hidden"
value={props.element.value as string}
onValueChange={(value) => {
// The value is managed by the slash input plugin
}}
autoFocus
/>
<CommandList className="max-h-[300px] overflow-y-auto p-1">
<CommandEmpty>No results found.</CommandEmpty>
{slashCommandGroups.map(({ heading, items }) => (
<CommandGroup key={heading} heading={heading}>
{items.map(({ icon, keywords, label, onSelect }) => (
<CommandItem
key={label}
className="flex items-center gap-2 px-2"
keywords={keywords}
value={label}
onSelect={() => {
editor.tf.removeNodes({
match: (n) => (n as any).type === SlashInputPlugin.key,
});
onSelect(editor);
editor.tf.focus();
}}
>
<span className="flex size-5 items-center justify-center text-muted-foreground">
{icon}
</span>
{label}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
{children}
</PlateElement>
);
}

View file

@ -0,0 +1,862 @@
'use client';
import type { LucideProps } from 'lucide-react';
export function BorderAllIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border All</title>
<path
clipRule="evenodd"
d="M0.25 1C0.25 0.585786 0.585786 0.25 1 0.25H14C14.4142 0.25 14.75 0.585786 14.75 1V14C14.75 14.4142 14.4142 14.75 14 14.75H1C0.585786 14.75 0.25 14.4142 0.25 14V1ZM1.75 1.75V13.25H13.25V1.75H1.75Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
</svg>
);
}
export function BorderBottomIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Bottom</title>
<path
clipRule="evenodd"
d="M1 13.25L14 13.25V14.75L1 14.75V13.25Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="11" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11" />
</svg>
);
}
export function BorderLeftIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Left</title>
<path
clipRule="evenodd"
d="M1.75 1L1.75 14L0.249999 14L0.25 1L1.75 1Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 10 7)"
width="1"
x="10"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 10 13)"
width="1"
x="10"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 12 7)"
width="1"
x="12"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 12 13)"
width="1"
x="12"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 7)"
width="1"
x="8"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 7)"
width="1"
x="14"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 13)"
width="1"
x="8"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 13)"
width="1"
x="14"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 5)"
width="1"
x="8"
y="5"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 5)"
width="1"
x="14"
y="5"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 3)"
width="1"
x="8"
y="3"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 3)"
width="1"
x="14"
y="3"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 9)"
width="1"
x="8"
y="9"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 9)"
width="1"
x="14"
y="9"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 11)"
width="1"
x="8"
y="11"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 11)"
width="1"
x="14"
y="11"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 6 7)"
width="1"
x="6"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 6 13)"
width="1"
x="6"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 4 7)"
width="1"
x="4"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 4 13)"
width="1"
x="4"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 10 1)"
width="1"
x="10"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 12 1)"
width="1"
x="12"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 1)"
width="1"
x="8"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 1)"
width="1"
x="14"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 6 1)"
width="1"
x="6"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 4 1)"
width="1"
x="4"
y="1"
/>
</svg>
);
}
export function BorderNoneIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border None</title>
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7.025" />
<rect
fill="currentColor"
height="1"
rx=".5"
width="1"
x="13"
y="13.025"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7.025" />
<rect
fill="currentColor"
height="1"
rx=".5"
width="1"
x="11"
y="13.025"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11.025" />
<rect
fill="currentColor"
height="1"
rx=".5"
width="1"
x="13"
y="11.025"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11.025" />
</svg>
);
}
export function BorderRightIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Right</title>
<path
clipRule="evenodd"
d="M13.25 1L13.25 14L14.75 14L14.75 1L13.25 1Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 5 7)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 5 13)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 3 7)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 3 13)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 7)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 7)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 13)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 13)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 5)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 5)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 3)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 3)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 9)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 9)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 11)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 11)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 9 7)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 9 13)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 11 7)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 11 13)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 5 1)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 3 1)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 7 1)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 1 1)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 9 1)"
width="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="matrix(0 1 1 0 11 1)"
width="1"
/>
</svg>
);
}
export function BorderTopIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Top</title>
<path
clipRule="evenodd"
d="M14 1.75L1 1.75L1 0.249999L14 0.25L14 1.75Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 10)"
width="1"
x="8"
y="10"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 10)"
width="1"
x="2"
y="10"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 12)"
width="1"
x="8"
y="12"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 12)"
width="1"
x="2"
y="12"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 8)"
width="1"
x="8"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 14)"
width="1"
x="8"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 8)"
width="1"
x="2"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 14)"
width="1"
x="2"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 10 8)"
width="1"
x="10"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 10 14)"
width="1"
x="10"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 12 8)"
width="1"
x="12"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 12 14)"
width="1"
x="12"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 6 8)"
width="1"
x="6"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 6 14)"
width="1"
x="6"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 4 8)"
width="1"
x="4"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 4 14)"
width="1"
x="4"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 6)"
width="1"
x="8"
y="6"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 6)"
width="1"
x="2"
y="6"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 4)"
width="1"
x="8"
y="4"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 4)"
width="1"
x="2"
y="4"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 10)"
width="1"
x="14"
y="10"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 12)"
width="1"
x="14"
y="12"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 8)"
width="1"
x="14"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 14)"
width="1"
x="14"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 6)"
width="1"
x="14"
y="6"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 4)"
width="1"
x="14"
y="4"
/>
</svg>
);
}

View file

@ -0,0 +1,101 @@
import * as React from 'react';
import type { TTableCellElement, TTableElement } from 'platejs';
import type { SlateElementProps } from 'platejs/static';
import { BaseTablePlugin } from '@platejs/table';
import { SlateElement } from 'platejs/static';
import { cn } from '@/lib/utils';
export function TableElementStatic({
children,
...props
}: SlateElementProps<TTableElement>) {
const { disableMarginLeft } = props.editor.getOptions(BaseTablePlugin);
const marginLeft = disableMarginLeft ? 0 : props.element.marginLeft;
return (
<SlateElement
{...props}
className="overflow-x-auto py-5"
style={{ paddingLeft: marginLeft }}
>
<div className="group/table relative w-fit">
<table
className="mr-0 ml-px table h-px table-fixed border-collapse"
style={{ borderCollapse: 'collapse', width: '100%' }}
>
<tbody className="min-w-full">{children}</tbody>
</table>
</div>
</SlateElement>
);
}
export function TableRowElementStatic(props: SlateElementProps) {
return (
<SlateElement {...props} as="tr" className="h-full">
{props.children}
</SlateElement>
);
}
export function TableCellElementStatic({
isHeader,
...props
}: SlateElementProps<TTableCellElement> & {
isHeader?: boolean;
}) {
const { editor, element } = props;
const { api } = editor.getPlugin(BaseTablePlugin);
const { minHeight, width } = api.table.getCellSize({ element });
const borders = api.table.getCellBorders({ element });
return (
<SlateElement
{...props}
as={isHeader ? 'th' : 'td'}
className={cn(
'h-full overflow-visible border-none bg-background p-0',
element.background ? 'bg-(--cellBackground)' : 'bg-background',
isHeader && 'text-left font-normal *:m-0',
'before:size-full',
"before:absolute before:box-border before:select-none before:content-['']",
borders &&
cn(
borders.bottom?.size && 'before:border-b before:border-b-border',
borders.right?.size && 'before:border-r before:border-r-border',
borders.left?.size && 'before:border-l before:border-l-border',
borders.top?.size && 'before:border-t before:border-t-border'
)
)}
style={
{
'--cellBackground': element.background,
maxWidth: width || 240,
minWidth: width || 120,
} as React.CSSProperties
}
attributes={{
...props.attributes,
colSpan: api.table.getColSpan(element),
rowSpan: api.table.getRowSpan(element),
}}
>
<div
className="relative z-20 box-border h-full px-4 py-2"
style={{ minHeight }}
>
{props.children}
</div>
</SlateElement>
);
}
export function TableCellHeaderElementStatic(
props: SlateElementProps<TTableCellElement>
) {
return <TableCellElementStatic {...props} isHeader />;
}

View file

@ -0,0 +1,657 @@
'use client';
import * as React from 'react';
import type * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { useDraggable, useDropLine } from '@platejs/dnd';
import {
BlockSelectionPlugin,
useBlockSelected,
} from '@platejs/selection/react';
import { setCellBackground } from '@platejs/table';
import {
TablePlugin,
TableProvider,
useTableBordersDropdownMenuContentState,
useTableCellElement,
useTableCellElementResizable,
useTableElement,
useTableMergeState,
} from '@platejs/table/react';
import { PopoverAnchor } from '@radix-ui/react-popover';
import { cva } from 'class-variance-authority';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CombineIcon,
EraserIcon,
Grid2X2Icon,
GripVertical,
PaintBucketIcon,
SquareSplitHorizontalIcon,
Trash2Icon,
XIcon,
} from 'lucide-react';
import {
type TElement,
type TTableCellElement,
type TTableElement,
type TTableRowElement,
KEYS,
PathApi,
} from 'platejs';
import {
type PlateElementProps,
PlateElement,
useComposedRef,
useEditorPlugin,
useEditorRef,
useEditorSelector,
useElement,
useFocusedLast,
usePluginOption,
useReadOnly,
useRemoveNodeButton,
useSelected,
withHOC,
} from 'platejs/react';
import { useElementSelector } from 'platejs/react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { blockSelectionVariants } from './block-selection';
import {
ColorDropdownMenuItems,
DEFAULT_COLORS,
} from './font-color-toolbar-button';
import { ResizeHandle } from './resize-handle';
import {
BorderAllIcon,
BorderBottomIcon,
BorderLeftIcon,
BorderNoneIcon,
BorderRightIcon,
BorderTopIcon,
} from './table-icons';
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarMenuGroup,
} from './toolbar';
export const TableElement = withHOC(
TableProvider,
function TableElement({
children,
...props
}: PlateElementProps<TTableElement>) {
const readOnly = useReadOnly();
const isSelectionAreaVisible = usePluginOption(
BlockSelectionPlugin,
'isSelectionAreaVisible'
);
const hasControls = !readOnly && !isSelectionAreaVisible;
const {
isSelectingCell,
marginLeft,
props: tableProps,
} = useTableElement();
const isSelectingTable = useBlockSelected(props.element.id as string);
const content = (
<PlateElement
{...props}
className={cn(
'overflow-x-auto py-5',
hasControls && '-ml-2 *:data-[slot=block-selection]:left-2'
)}
style={{ paddingLeft: marginLeft }}
>
<div className="group/table relative w-fit">
<table
className={cn(
'mr-0 ml-px table h-px w-full table-fixed border-collapse',
isSelectingCell && 'selection:bg-transparent'
)}
{...tableProps}
>
<tbody className="min-w-full">{children}</tbody>
</table>
{isSelectingTable && (
<div className={blockSelectionVariants()} contentEditable={false} />
)}
</div>
</PlateElement>
);
if (readOnly) {
return content;
}
return <TableFloatingToolbar>{content}</TableFloatingToolbar>;
}
);
function TableFloatingToolbar({
children,
...props
}: React.ComponentProps<typeof PopoverContent>) {
const { tf } = useEditorPlugin(TablePlugin);
const selected = useSelected();
const element = useElement<TTableElement>();
const { props: buttonProps } = useRemoveNodeButton({ element });
const collapsedInside = useEditorSelector(
(editor) => selected && editor.api.isCollapsed(),
[selected]
);
const isFocusedLast = useFocusedLast();
const { canMerge, canSplit } = useTableMergeState();
return (
<Popover
open={isFocusedLast && (canMerge || canSplit || collapsedInside)}
modal={false}
>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
asChild
onOpenAutoFocus={(e) => e.preventDefault()}
contentEditable={false}
{...props}
>
<Toolbar
className="scrollbar-hide flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border bg-popover p-1 shadow-md print:hidden"
contentEditable={false}
>
<ToolbarGroup>
<ColorDropdownMenu tooltip="Background color">
<PaintBucketIcon />
</ColorDropdownMenu>
{canMerge && (
<ToolbarButton
onClick={() => tf.table.merge()}
onMouseDown={(e) => e.preventDefault()}
tooltip="Merge cells"
>
<CombineIcon />
</ToolbarButton>
)}
{canSplit && (
<ToolbarButton
onClick={() => tf.table.split()}
onMouseDown={(e) => e.preventDefault()}
tooltip="Split cell"
>
<SquareSplitHorizontalIcon />
</ToolbarButton>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<ToolbarButton tooltip="Cell borders">
<Grid2X2Icon />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<TableBordersDropdownMenuContent />
</DropdownMenuPortal>
</DropdownMenu>
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton tooltip="Delete table" {...buttonProps}>
<Trash2Icon />
</ToolbarButton>
</ToolbarGroup>
)}
</ToolbarGroup>
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
onClick={() => {
tf.insert.tableRow({ before: true });
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert row before"
>
<ArrowUp />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.insert.tableRow();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert row after"
>
<ArrowDown />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.remove.tableRow();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Delete row"
>
<XIcon />
</ToolbarButton>
</ToolbarGroup>
)}
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
onClick={() => {
tf.insert.tableColumn({ before: true });
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert column before"
>
<ArrowLeft />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.insert.tableColumn();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert column after"
>
<ArrowRight />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.remove.tableColumn();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Delete column"
>
<XIcon />
</ToolbarButton>
</ToolbarGroup>
)}
</Toolbar>
</PopoverContent>
</Popover>
);
}
function TableBordersDropdownMenuContent(
props: React.ComponentProps<typeof DropdownMenuPrimitive.Content>
) {
const editor = useEditorRef();
const {
getOnSelectTableBorder,
hasBottomBorder,
hasLeftBorder,
hasNoBorders,
hasOuterBorders,
hasRightBorder,
hasTopBorder,
} = useTableBordersDropdownMenuContentState();
return (
<DropdownMenuContent
className="min-w-[220px]"
onCloseAutoFocus={(e) => {
e.preventDefault();
editor.tf.focus();
}}
align="start"
side="right"
sideOffset={0}
{...props}
>
<DropdownMenuGroup>
<DropdownMenuCheckboxItem
checked={hasTopBorder}
onCheckedChange={getOnSelectTableBorder('top')}
>
<BorderTopIcon />
<div>Top Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hasRightBorder}
onCheckedChange={getOnSelectTableBorder('right')}
>
<BorderRightIcon />
<div>Right Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hasBottomBorder}
onCheckedChange={getOnSelectTableBorder('bottom')}
>
<BorderBottomIcon />
<div>Bottom Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hasLeftBorder}
onCheckedChange={getOnSelectTableBorder('left')}
>
<BorderLeftIcon />
<div>Left Border</div>
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuCheckboxItem
checked={hasNoBorders}
onCheckedChange={getOnSelectTableBorder('none')}
>
<BorderNoneIcon />
<div>No Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hasOuterBorders}
onCheckedChange={getOnSelectTableBorder('outer')}
>
<BorderAllIcon />
<div>Outside Borders</div>
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuContent>
);
}
function ColorDropdownMenu({
children,
tooltip,
}: {
children: React.ReactNode;
tooltip: string;
}) {
const [open, setOpen] = React.useState(false);
const editor = useEditorRef();
const selectedCells = usePluginOption(TablePlugin, 'selectedCells');
const onUpdateColor = React.useCallback(
(color: string) => {
setOpen(false);
setCellBackground(editor, { color, selectedCells: selectedCells ?? [] });
},
[selectedCells, editor]
);
const onClearColor = React.useCallback(() => {
setOpen(false);
setCellBackground(editor, {
color: null,
selectedCells: selectedCells ?? [],
});
}, [selectedCells, editor]);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<ToolbarButton tooltip={tooltip}>{children}</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<ToolbarMenuGroup label="Colors">
<ColorDropdownMenuItems
className="px-2"
colors={DEFAULT_COLORS}
updateColor={onUpdateColor}
/>
</ToolbarMenuGroup>
<DropdownMenuGroup>
<DropdownMenuItem className="p-2" onClick={onClearColor}>
<EraserIcon />
<span>Clear</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function TableRowElement({
children,
...props
}: PlateElementProps<TTableRowElement>) {
const { element } = props;
const readOnly = useReadOnly();
const selected = useSelected();
const editor = useEditorRef();
const isSelectionAreaVisible = usePluginOption(
BlockSelectionPlugin,
'isSelectionAreaVisible'
);
const hasControls = !readOnly && !isSelectionAreaVisible;
const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({
element,
type: element.type,
canDropNode: ({ dragEntry, dropEntry }) =>
PathApi.equals(
PathApi.parent(dragEntry[1]),
PathApi.parent(dropEntry[1])
),
onDropHandler: (_, { dragItem }) => {
const dragElement = (dragItem as { element: TElement }).element;
if (dragElement) {
editor.tf.select(dragElement);
}
},
});
return (
<PlateElement
{...props}
ref={useComposedRef(props.ref, previewRef, nodeRef)}
as="tr"
className={cn('group/row', isDragging && 'opacity-50')}
attributes={{
...props.attributes,
'data-selected': selected ? 'true' : undefined,
}}
>
{hasControls && (
<td className="w-2 select-none" contentEditable={false}>
<RowDragHandle dragRef={handleRef} />
<RowDropLine />
</td>
)}
{children}
</PlateElement>
);
}
function RowDragHandle({ dragRef }: { dragRef: React.Ref<any> }) {
const editor = useEditorRef();
const element = useElement();
return (
<Button
ref={dragRef}
variant="outline"
className={cn(
'-translate-y-1/2 absolute top-1/2 left-0 z-51 h-6 w-4 p-0 focus-visible:ring-0 focus-visible:ring-offset-0',
'cursor-grab active:cursor-grabbing',
'opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-data-[resizing="true"]/row:opacity-0'
)}
onClick={() => {
editor.tf.select(element);
}}
>
<GripVertical className="text-muted-foreground" />
</Button>
);
}
function RowDropLine() {
const { dropLine } = useDropLine();
if (!dropLine) return null;
return (
<div
className={cn(
'absolute inset-x-0 left-2 z-50 h-0.5 bg-brand/50',
dropLine === 'top' ? '-top-px' : '-bottom-px'
)}
/>
);
}
export function TableCellElement({
isHeader,
...props
}: PlateElementProps<TTableCellElement> & {
isHeader?: boolean;
}) {
const { api } = useEditorPlugin(TablePlugin);
const readOnly = useReadOnly();
const element = props.element;
const tableId = useElementSelector(([node]) => node.id as string, [], {
key: KEYS.table,
});
const rowId = useElementSelector(([node]) => node.id as string, [], {
key: KEYS.tr,
});
const isSelectingTable = useBlockSelected(tableId);
const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;
const isSelectionAreaVisible = usePluginOption(
BlockSelectionPlugin,
'isSelectionAreaVisible'
);
const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } =
useTableCellElement();
const { bottomProps, hiddenLeft, leftProps, rightProps } =
useTableCellElementResizable({
colIndex,
colSpan,
rowIndex,
});
return (
<PlateElement
{...props}
as={isHeader ? 'th' : 'td'}
className={cn(
'h-full overflow-visible border-none bg-background p-0',
element.background ? 'bg-(--cellBackground)' : 'bg-background',
isHeader && 'text-left *:m-0',
'before:size-full',
selected && 'before:z-10 before:bg-brand/5',
"before:absolute before:box-border before:select-none before:content-['']",
borders.bottom?.size && 'before:border-b before:border-b-border',
borders.right?.size && 'before:border-r before:border-r-border',
borders.left?.size && 'before:border-l before:border-l-border',
borders.top?.size && 'before:border-t before:border-t-border'
)}
style={
{
'--cellBackground': element.background,
minWidth: width || 48,
} as React.CSSProperties
}
attributes={{
...props.attributes,
colSpan: api.table.getColSpan(element),
rowSpan: api.table.getRowSpan(element),
}}
>
<div
className="relative z-20 box-border h-full px-3 py-2"
style={{ minHeight }}
>
{props.children}
</div>
{!isSelectionAreaVisible && (
<div
className="group absolute top-0 size-full select-none"
contentEditable={false}
suppressContentEditableWarning={true}
>
{!readOnly && (
<>
<ResizeHandle
{...rightProps}
className="-top-2 -right-1 h-[calc(100%_+_8px)] w-2"
data-col={colIndex}
/>
<ResizeHandle {...bottomProps} className="-bottom-1 h-2" />
{!hiddenLeft && (
<ResizeHandle
{...leftProps}
className="-left-1 top-0 w-2"
data-resizer-left={colIndex === 0 ? 'true' : undefined}
/>
)}
<div
className={cn(
'absolute top-0 z-30 hidden h-full w-1 bg-ring',
'right-[-1.5px]',
columnResizeVariants({ colIndex: colIndex as any })
)}
/>
{colIndex === 0 && (
<div
className={cn(
'absolute top-0 z-30 h-full w-1 bg-ring',
'left-[-1.5px]',
'fade-in hidden animate-in group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing="true"]]/table:block'
)}
/>
)}
</>
)}
</div>
)}
{isSelectingRow && (
<div className={blockSelectionVariants()} contentEditable={false} />
)}
</PlateElement>
);
}
export function TableCellHeaderElement(
props: React.ComponentProps<typeof TableCellElement>
) {
return <TableCellElement {...props} isHeader />;
}
const columnResizeVariants = cva('fade-in hidden animate-in', {
variants: {
colIndex: {
0: 'group-has-[[data-col="0"]:hover]/table:block group-has-[[data-col="0"][data-resizing="true"]]/table:block',
1: 'group-has-[[data-col="1"]:hover]/table:block group-has-[[data-col="1"][data-resizing="true"]]/table:block',
2: 'group-has-[[data-col="2"]:hover]/table:block group-has-[[data-col="2"][data-resizing="true"]]/table:block',
3: 'group-has-[[data-col="3"]:hover]/table:block group-has-[[data-col="3"][data-resizing="true"]]/table:block',
4: 'group-has-[[data-col="4"]:hover]/table:block group-has-[[data-col="4"][data-resizing="true"]]/table:block',
5: 'group-has-[[data-col="5"]:hover]/table:block group-has-[[data-col="5"][data-resizing="true"]]/table:block',
6: 'group-has-[[data-col="6"]:hover]/table:block group-has-[[data-col="6"][data-resizing="true"]]/table:block',
7: 'group-has-[[data-col="7"]:hover]/table:block group-has-[[data-col="7"][data-resizing="true"]]/table:block',
8: 'group-has-[[data-col="8"]:hover]/table:block group-has-[[data-col="8"][data-resizing="true"]]/table:block',
9: 'group-has-[[data-col="9"]:hover]/table:block group-has-[[data-col="9"][data-resizing="true"]]/table:block',
10: 'group-has-[[data-col="10"]:hover]/table:block group-has-[[data-col="10"][data-resizing="true"]]/table:block',
},
},
});

View file

@ -0,0 +1,266 @@
'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 { 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">
<Grid3x3Icon className="size-4" />
<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();
}}
>
<Combine />
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();
}}
>
<XIcon />
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();
}}
>
<XIcon />
Delete column
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className="min-w-[180px]"
disabled={!tableSelected}
onSelect={() => {
tf.remove.table();
editor.tf.focus();
}}
>
<Trash2Icon />
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="flex! m-0 flex-col p-0"
onClick={() => {
tf.insert.table(tablePicker.size, { select: true });
editor.tf.focus();
}}
role="button"
>
<div className="grid size-[130px] grid-cols-8 gap-0.5 p-1">
{tablePicker.grid.map((rows, rowIndex) =>
rows.map((value, columIndex) => (
<div
key={`(${rowIndex},${columIndex})`}
className={cn(
'col-span-1 size-3 border border-solid bg-secondary',
!!value && 'border-current'
)}
onMouseMove={() => {
onCellMove(rowIndex, columIndex);
}}
/>
))
)}
</div>
<div className="text-center text-current text-xs">
{tablePicker.size.rowCount} x {tablePicker.size.colCount}
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import { useToggleButton, useToggleButtonState } from '@platejs/toggle/react';
import { ChevronRightIcon } from 'lucide-react';
import { type PlateElementProps, PlateElement } from 'platejs/react';
import { cn } from '@/lib/utils';
export function ToggleElement({
children,
...props
}: PlateElementProps) {
const element = props.element;
const state = useToggleButtonState(element.id as string);
const { buttonProps, open } = useToggleButton(state);
return (
<PlateElement {...props} className="relative py-1 pl-6">
<button
className={cn(
'absolute top-1.5 left-0 flex size-6 cursor-pointer select-none items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
)}
contentEditable={false}
type="button"
{...buttonProps}
>
<ChevronRightIcon
className={cn(
'size-4 transition-transform duration-200',
open && 'rotate-90'
)}
/>
</button>
<div>{children}</div>
</PlateElement>
);
}

View file

@ -0,0 +1,389 @@
'use client';
import * as React from 'react';
import * as ToolbarPrimitive from '@radix-ui/react-toolbar';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { type VariantProps, cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export function Toolbar({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
return (
<ToolbarPrimitive.Root
className={cn('relative flex select-none items-center', className)}
{...props}
/>
);
}
export function ToolbarToggleGroup({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.ToolbarToggleGroup>) {
return (
<ToolbarPrimitive.ToolbarToggleGroup
className={cn('flex items-center', className)}
{...props}
/>
);
}
export function ToolbarLink({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.Link>) {
return (
<ToolbarPrimitive.Link
className={cn('font-medium underline underline-offset-4', className)}
{...props}
/>
);
}
export function ToolbarSeparator({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
return (
<ToolbarPrimitive.Separator
className={cn('mx-2 my-1 w-px shrink-0 bg-border', className)}
{...props}
/>
);
}
// From toggleVariants
const toolbarButtonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-checked:bg-accent aria-checked:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
defaultVariants: {
size: 'default',
variant: 'default',
},
variants: {
size: {
default: 'h-9 min-w-9 px-2',
lg: 'h-10 min-w-10 px-2.5',
sm: 'h-8 min-w-8 px-1.5',
},
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
},
}
);
const dropdownArrowVariants = cva(
cn(
'inline-flex items-center justify-center rounded-r-md font-medium text-foreground text-sm transition-colors disabled:pointer-events-none disabled:opacity-50'
),
{
defaultVariants: {
size: 'sm',
variant: 'default',
},
variants: {
size: {
default: 'h-9 w-6',
lg: 'h-10 w-8',
sm: 'h-8 w-4',
},
variant: {
default:
'bg-transparent hover:bg-muted hover:text-muted-foreground aria-checked:bg-accent aria-checked:text-accent-foreground',
outline:
'border border-input border-l-0 bg-transparent hover:bg-accent hover:text-accent-foreground',
},
},
}
);
type ToolbarButtonProps = {
isDropdown?: boolean;
pressed?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
'asChild' | 'value'
> &
VariantProps<typeof toolbarButtonVariants>;
export const ToolbarButton = withTooltip(function ToolbarButton({
children,
className,
isDropdown,
pressed,
size = 'sm',
variant,
...props
}: ToolbarButtonProps) {
return typeof pressed === 'boolean' ? (
<ToolbarToggleGroup disabled={props.disabled} value="single" type="single">
<ToolbarToggleItem
className={cn(
toolbarButtonVariants({
size,
variant,
}),
isDropdown && 'justify-between gap-1 pr-1',
className
)}
value={pressed ? 'single' : ''}
{...props}
>
{isDropdown ? (
<>
<div className="flex flex-1 items-center gap-2 whitespace-nowrap">
{children}
</div>
<div>
<ChevronDown
className="size-3.5 text-muted-foreground"
data-icon
/>
</div>
</>
) : (
children
)}
</ToolbarToggleItem>
</ToolbarToggleGroup>
) : (
<ToolbarPrimitive.Button
className={cn(
toolbarButtonVariants({
size,
variant,
}),
isDropdown && 'pr-1',
className
)}
{...props}
>
{children}
</ToolbarPrimitive.Button>
);
});
export function ToolbarSplitButton({
className,
...props
}: React.ComponentPropsWithoutRef<typeof ToolbarButton>) {
return (
<ToolbarButton
className={cn('group flex gap-0 px-0 hover:bg-transparent', className)}
{...props}
/>
);
}
type ToolbarSplitButtonPrimaryProps = Omit<
React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
'value'
> &
VariantProps<typeof toolbarButtonVariants>;
export function ToolbarSplitButtonPrimary({
children,
className,
size = 'sm',
variant,
...props
}: ToolbarSplitButtonPrimaryProps) {
return (
<span
className={cn(
toolbarButtonVariants({
size,
variant,
}),
'rounded-r-none',
'group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground',
className
)}
{...props}
>
{children}
</span>
);
}
export function ToolbarSplitButtonSecondary({
className,
size,
variant,
...props
}: React.ComponentPropsWithoutRef<'span'> &
VariantProps<typeof dropdownArrowVariants>) {
return (
<span
className={cn(
dropdownArrowVariants({
size,
variant,
}),
'group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground',
className
)}
onClick={(e) => e.stopPropagation()}
role="button"
{...props}
>
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
</span>
);
}
export function ToolbarToggleItem({
className,
size = 'sm',
variant,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.ToggleItem> &
VariantProps<typeof toolbarButtonVariants>) {
return (
<ToolbarPrimitive.ToggleItem
className={cn(toolbarButtonVariants({ size, variant }), className)}
{...props}
/>
);
}
export function ToolbarGroup({
children,
className,
}: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'group/toolbar-group',
'relative hidden has-[button]:flex',
className
)}
>
<div className="flex items-center">{children}</div>
<div className="group-last/toolbar-group:hidden! mx-1.5 py-0.5">
<Separator orientation="vertical" />
</div>
</div>
);
}
type TooltipProps<T extends React.ElementType> = {
tooltip?: React.ReactNode;
tooltipContentProps?: Omit<
React.ComponentPropsWithoutRef<typeof TooltipContent>,
'children'
>;
tooltipProps?: Omit<
React.ComponentPropsWithoutRef<typeof Tooltip>,
'children'
>;
tooltipTriggerProps?: React.ComponentPropsWithoutRef<typeof TooltipTrigger>;
} & React.ComponentProps<T>;
function withTooltip<T extends React.ElementType>(Component: T) {
return function ExtendComponent({
tooltip,
tooltipContentProps,
tooltipProps,
tooltipTriggerProps,
...props
}: TooltipProps<T>) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const component = <Component {...(props as React.ComponentProps<T>)} />;
if (tooltip && mounted) {
return (
<Tooltip {...tooltipProps}>
<TooltipTrigger asChild {...tooltipTriggerProps}>
{component}
</TooltipTrigger>
<TooltipContent {...tooltipContentProps}>{tooltip}</TooltipContent>
</Tooltip>
);
}
return component;
};
}
function TooltipContent({
children,
className,
// CHANGE
sideOffset = 4,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) text-balance rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-xs',
className
)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
>
{children}
{/* CHANGE */}
{/* <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" /> */}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export function ToolbarMenuGroup({
children,
className,
label,
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup> & { label?: string }) {
return (
<>
<DropdownMenuSeparator
className={cn(
'hidden',
'mb-0 shrink-0 peer-has-[[role=menuitem]]/menu-group:block peer-has-[[role=menuitemradio]]/menu-group:block peer-has-[[role=option]]/menu-group:block'
)}
/>
<DropdownMenuRadioGroup
{...props}
className={cn(
'hidden',
'peer/menu-group group/menu-group my-1.5 has-[[role=menuitem]]:block has-[[role=menuitemradio]]:block has-[[role=option]]:block',
className
)}
>
{label && (
<DropdownMenuLabel className="select-none font-semibold text-muted-foreground text-xs">
{label}
</DropdownMenuLabel>
)}
{children}
</DropdownMenuRadioGroup>
</>
);
}

View file

@ -0,0 +1,191 @@
'use client';
import * as React from 'react';
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import type { TElement } from 'platejs';
import { DropdownMenuItemIndicator } from '@radix-ui/react-dropdown-menu';
import {
CheckIcon,
ChevronRightIcon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
InfoIcon,
ListIcon,
ListOrderedIcon,
PilcrowIcon,
QuoteIcon,
SquareIcon,
} from 'lucide-react';
import { KEYS } from 'platejs';
import { useEditorRef, useSelectionFragmentProp } from 'platejs/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
getBlockType,
setBlockType,
} from '@/components/editor/transforms';
import { ToolbarButton, ToolbarMenuGroup } from './toolbar';
export const turnIntoItems = [
{
icon: <PilcrowIcon />,
keywords: ['paragraph'],
label: 'Text',
value: KEYS.p,
},
{
icon: <Heading1Icon />,
keywords: ['title', 'h1'],
label: 'Heading 1',
value: 'h1',
},
{
icon: <Heading2Icon />,
keywords: ['subtitle', 'h2'],
label: 'Heading 2',
value: 'h2',
},
{
icon: <Heading3Icon />,
keywords: ['subtitle', 'h3'],
label: 'Heading 3',
value: 'h3',
},
{
icon: <Heading4Icon />,
keywords: ['subtitle', 'h4'],
label: 'Heading 4',
value: 'h4',
},
{
icon: <Heading5Icon />,
keywords: ['subtitle', 'h5'],
label: 'Heading 5',
value: 'h5',
},
{
icon: <Heading6Icon />,
keywords: ['subtitle', 'h6'],
label: 'Heading 6',
value: 'h6',
},
{
icon: <ListIcon />,
keywords: ['unordered', 'ul', '-'],
label: 'Bulleted list',
value: KEYS.ul,
},
{
icon: <ListOrderedIcon />,
keywords: ['ordered', 'ol', '1'],
label: 'Numbered list',
value: KEYS.ol,
},
{
icon: <SquareIcon />,
keywords: ['checklist', 'task', 'checkbox', '[]'],
label: 'To-do list',
value: KEYS.listTodo,
},
{
icon: <FileCodeIcon />,
keywords: ['```'],
label: 'Code',
value: KEYS.codeBlock,
},
{
icon: <QuoteIcon />,
keywords: ['citation', 'blockquote', '>'],
label: 'Quote',
value: KEYS.blockquote,
},
{
icon: <InfoIcon />,
keywords: ['callout', 'note', 'info', 'warning', 'tip'],
label: 'Callout',
value: KEYS.callout,
},
{
icon: <ChevronRightIcon />,
keywords: ['toggle', 'collapsible', 'expand'],
label: 'Toggle',
value: KEYS.toggle,
},
];
export function TurnIntoToolbarButton(props: DropdownMenuProps) {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
const value = useSelectionFragmentProp({
defaultValue: KEYS.p,
getProp: (node) => getBlockType(node as TElement),
});
const selectedItem = React.useMemo(
() =>
turnIntoItems.find((item) => item.value === (value ?? KEYS.p)) ??
turnIntoItems[0],
[value]
);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton
className="min-w-[125px]"
pressed={open}
tooltip="Turn into"
isDropdown
>
{selectedItem.label}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="ignore-click-outside/toolbar min-w-0"
onCloseAutoFocus={(e) => {
e.preventDefault();
editor.tf.focus();
}}
align="start"
>
<ToolbarMenuGroup
value={value}
onValueChange={(type) => {
setBlockType(editor, type);
}}
label="Turn into"
>
{turnIntoItems.map(({ icon, label, value: itemValue }) => (
<DropdownMenuRadioItem
key={itemValue}
className="min-w-[180px] pl-2 *:first:[span]:hidden"
value={itemValue}
>
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<CheckIcon />
</DropdownMenuItemIndicator>
</span>
{icon}
{label}
</DropdownMenuRadioItem>
))}
</ToolbarMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,18 @@
import * as React from 'react';
export const useDebounce = <T>(value: T, delay = 500) => {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const handler: NodeJS.Timeout = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};

View file

@ -0,0 +1,11 @@
import * as React from 'react';
export function useMounted() {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
return mounted;
}

View file

@ -35,6 +35,23 @@
"@electric-sql/react": "^1.0.26",
"@hookform/resolvers": "^5.2.2",
"@number-flow/react": "^0.5.10",
"@platejs/autoformat": "^52.0.11",
"@platejs/basic-nodes": "^52.0.11",
"@platejs/callout": "^52.0.11",
"@platejs/code-block": "^52.0.11",
"@platejs/dnd": "^52.0.11",
"@platejs/floating": "^52.0.11",
"@platejs/indent": "^52.0.11",
"@platejs/layout": "^52.0.11",
"@platejs/link": "^52.0.11",
"@platejs/list-classic": "^52.0.11",
"@platejs/markdown": "^52.1.0",
"@platejs/math": "^52.0.11",
"@platejs/resizable": "^52.0.11",
"@platejs/selection": "^52.0.16",
"@platejs/slash-command": "^52.0.15",
"@platejs/table": "^52.0.11",
"@platejs/toggle": "^52.0.11",
"@posthog/react": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
@ -56,6 +73,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-toolbar": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
@ -66,6 +84,7 @@
"@tanstack/react-table": "^8.21.3",
"@types/mdx": "^2.0.13",
"@types/react-syntax-highlighter": "^15.5.13",
"@udecode/cn": "^52.0.11",
"ai": "^4.3.19",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
@ -82,17 +101,21 @@
"jotai": "^2.15.1",
"jotai-tanstack-query": "^0.11.0",
"katex": "^0.16.28",
"lodash": "^4.17.23",
"lowlight": "^3.3.0",
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^16.1.0",
"next-intl": "^4.6.1",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"platejs": "^52.0.17",
"postgres": "^3.4.7",
"posthog-js": "^1.336.1",
"posthog-node": "^5.24.4",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-day-picker": "^9.8.1",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.3",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.61.1",
@ -108,6 +131,7 @@
"sonner": "^2.0.6",
"streamdown": "^2.2.0",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar-hide": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"vaul": "^1.1.2",

File diff suppressed because it is too large Load diff