ToS Editor

This commit is contained in:
2025-07-06 17:27:06 +02:00
parent af4c0dcdac
commit fe66ab76b2
21 changed files with 2702 additions and 38 deletions

1691
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,10 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@platejs/basic-nodes": "^49.0.0",
"@platejs/code-block": "^49.0.0",
"@platejs/indent": "^49.0.0",
"@platejs/list": "^49.0.0",
"@platejs/markdown": "^49.0.17",
"@prisma/client": "^6.11.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
@ -36,6 +40,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lowlight": "^3.3.0",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-auth": "^5.0.0-beta.29",
@ -44,6 +49,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.59.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar-hide": "^4.0.0",

View File

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "TermsOfService" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"version" SERIAL NOT NULL,
"markdown" TEXT NOT NULL,
CONSTRAINT "TermsOfService_pkey" PRIMARY KEY ("id")
);

View File

@ -91,3 +91,12 @@ model CommissionTypeExtra {
@@unique([typeId, extraId])
}
model TermsOfService {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
version Int @default(autoincrement())
markdown String
}

View File

@ -0,0 +1,10 @@
'use server';
import prisma from "@/lib/prisma";
export async function getLatestTos(): Promise<string | null> {
const tos = await prisma.termsOfService.findFirst({
orderBy: { createdAt: 'desc' },
});
return tos?.markdown ?? null;
}

View File

@ -0,0 +1,11 @@
'use server';
import prisma from "@/lib/prisma";
export async function saveTosAction(markdown: string) {
await prisma.termsOfService.create({
data: {
markdown,
},
});
}

View File

@ -1,13 +1,16 @@
import { getLatestTos } from "@/actions/items/commissions/tos/getTos";
import TosEditor from "@/components/items/commissions/tos/TosEditor";
export default function TosPage() {
export default async function TosPage() {
const markdown = await getLatestTos();
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Terms of Service</h1>
</div>
<div className="space-y-4 p-1 border rounded-xl bg-muted/20">
<TosEditor />
<TosEditor markdown={markdown} />
</div>
</div>
);

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 @@
import { BaseIndentPlugin } from '@platejs/indent';
import { KEYS } from 'platejs';
export const BaseIndentKit = [
BaseIndentPlugin.configure({
inject: {
targetPlugins: [
...KEYS.heading,
KEYS.p,
KEYS.blockquote,
KEYS.codeBlock,
KEYS.toggle,
],
},
options: {
offset: 24,
},
}),
];

View File

@ -0,0 +1,21 @@
'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,
],
},
options: {
offset: 24,
},
}),
];

View File

@ -0,0 +1,23 @@
import { BaseListPlugin } from '@platejs/list';
import { KEYS } from 'platejs';
import { BaseIndentKit } from '@/components/editor/plugins/indent-base-kit';
import { BlockListStatic } from '@/components/ui/block-list-static';
export const BaseListKit = [
...BaseIndentKit,
BaseListPlugin.configure({
inject: {
targetPlugins: [
...KEYS.heading,
KEYS.p,
KEYS.blockquote,
KEYS.codeBlock,
KEYS.toggle,
],
},
render: {
belowNodes: BlockListStatic,
},
}),
];

View File

@ -0,0 +1,25 @@
'use client';
import { ListPlugin } from '@platejs/list/react';
import { KEYS } from 'platejs';
import { IndentKit } from '@/components/editor/plugins/indent-kit';
import { BlockList } from '@/components/ui/block-list';
export const ListKit = [
...IndentKit,
ListPlugin.configure({
inject: {
targetPlugins: [
...KEYS.heading,
KEYS.p,
KEYS.blockquote,
KEYS.codeBlock,
KEYS.toggle,
],
},
render: {
belowNodes: BlockList,
},
}),
];

View File

@ -0,0 +1,13 @@
import { MarkdownPlugin, remarkMdx, remarkMention } from '@platejs/markdown';
import { KEYS } from 'platejs';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
export const MarkdownKit = [
MarkdownPlugin.configure({
options: {
disallowedNodes: [KEYS.suggestion],
remarkPlugins: [remarkMath, remarkGfm, remarkMdx, remarkMention],
},
}),
];

View File

@ -4,57 +4,107 @@ import type { Value } from 'platejs';
import { BasicBlocksKit } from '@/components/editor/plugins/basic-blocks-kit';
import { BasicMarksKit } from '@/components/editor/plugins/basic-marks-kit';
import { MarkdownKit } from '@/components/editor/plugins/markdown-kit';
import { Editor, EditorContainer } from '@/components/ui/editor';
import { FixedToolbar } from '@/components/ui/fixed-toolbar';
import { MarkToolbarButton } from '@/components/ui/mark-toolbar-button';
import { ToolbarButton } from '@/components/ui/toolbar';
import {
Bold,
Braces,
Code,
Heading1,
Heading2,
Heading3,
Italic,
Quote,
Save,
Strikethrough,
Underline
} from "lucide-react";
import { Plate, usePlateEditor } from 'platejs/react';
import { useEffect } from 'react';
import { saveTosAction } from '@/actions/items/commissions/tos/saveTosAction';
import { CodeBlockKit } from '@/components/editor/plugins/code-block-kit';
import { ListKit } from '@/components/editor/plugins/list-kit';
import { BulletedListToolbarButton, NumberedListToolbarButton } from '@/components/ui/list-toolbar-button';
const initialValue: Value = [
{
children: [{ text: 'Title' }],
type: 'h3',
},
{
children: [{ text: 'This is a quote.' }],
type: 'blockquote',
},
{
type: 'p',
children: [
{ text: 'Hello! Try out the ' },
{ text: 'bold', bold: true },
{ text: ', ' },
{ text: 'italic', italic: true },
{ text: ', and ' },
{ text: 'underline', underline: true },
{ text: ' formatting.' },
],
},
];
export default function TosEditor() {
export default function TosEditor({ markdown }: { markdown: string | null }) {
// const [isSaving, setIsSaving] = useState(false);
const editor = usePlateEditor({
plugins: [
...BasicBlocksKit,
...CodeBlockKit,
...ListKit,
...BasicMarksKit,
], // Add the mark plugins
value: initialValue, // Set initial content
...MarkdownKit,
],
value: initialValue,
});
useEffect(() => {
if (markdown && editor.api.markdown.deserialize) {
const markdownValue = editor.api.markdown.deserialize(markdown);
console.log(markdownValue);
editor.children = markdownValue;
}
}, [editor, markdown]);
const handleSave = async () => {
console.log(editor);
if (!editor.api.markdown.serialize) return;
// setIsSaving(true);
const markdown = editor.api.markdown.serialize();
await saveTosAction(markdown);
// setIsSaving(false);
};
return (
<Plate editor={editor}> {/* Provides editor context */}
<FixedToolbar className="justify-start rounded-t-lg">
{/* Element Toolbar Buttons */}
<ToolbarButton onClick={() => editor.tf.h1.toggle()}>H1</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h2.toggle()}>H2</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h3.toggle()}>H3</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.blockquote.toggle()}>Quote</ToolbarButton>
{/* Blocks */}
<ToolbarButton onClick={() => editor.tf.h1.toggle()} tooltip="Heading 1">
<Heading1 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h2.toggle()} tooltip="Heading 2">
<Heading2 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h3.toggle()} tooltip="Heading 3">
<Heading3 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.blockquote.toggle()} tooltip="Blockquote">
<Quote className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.code_block.toggle()} tooltip="Code Block">
<Braces className="w-4 h-4" />
</ToolbarButton>
<BulletedListToolbarButton />
<NumberedListToolbarButton />
{/* Mark Toolbar Buttons */}
<MarkToolbarButton nodeType="bold" tooltip="Bold (⌘+B)">B</MarkToolbarButton>
<MarkToolbarButton nodeType="italic" tooltip="Italic (⌘+I)">I</MarkToolbarButton>
<MarkToolbarButton nodeType="underline" tooltip="Underline (⌘+U)">U</MarkToolbarButton>
<MarkToolbarButton nodeType="bold" tooltip="Bold">
<Bold className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="italic" tooltip="Italic">
<Italic className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="underline" tooltip="Underline">
<Underline className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="strikethrough" tooltip="Strikethrough">
<Strikethrough className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="code" tooltip="Code">
<Code className="w-4 h-4" />
</MarkToolbarButton>
{/* Save Button */}
<ToolbarButton onClick={handleSave} tooltip="Save">
<Save className="w-4 h-4" />
</ToolbarButton>
</FixedToolbar>
<EditorContainer> {/* Styles the editor area */}
<Editor placeholder="Type your amazing content here..." />

View File

@ -0,0 +1,83 @@
import * as React from 'react';
import type {
RenderStaticNodeWrapper,
SlateRenderElementProps,
TListElement,
} from 'platejs';
import { isOrderedList } from '@platejs/list';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
const config: Record<
string,
{
Li: React.FC<SlateRenderElementProps>;
Marker: React.FC<SlateRenderElementProps>;
}
> = {
todo: {
Li: TodoLiStatic,
Marker: TodoMarkerStatic,
},
};
export const BlockListStatic: RenderStaticNodeWrapper = (props) => {
if (!props.element.listStyleType) return;
return (props) => <List {...props} />;
};
function List(props: SlateRenderElementProps) {
const { listStart, listStyleType } = props.element as TListElement;
const { Li, Marker } = config[listStyleType] ?? {};
const List = isOrderedList(props.element) ? 'ol' : 'ul';
return (
<List
className="relative m-0 p-0"
style={{ listStyleType }}
start={listStart}
>
{Marker && <Marker {...props} />}
{Li ? <Li {...props} /> : <li>{props.children}</li>}
</List>
);
}
function TodoMarkerStatic(props: SlateRenderElementProps) {
const checked = props.element.checked as boolean;
return (
<div contentEditable={false}>
<button
className={cn(
'peer pointer-events-none absolute top-1 -left-6 size-4 shrink-0 rounded-sm border border-primary bg-background ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.className
)}
data-state={checked ? 'checked' : 'unchecked'}
type="button"
>
<div className={cn('flex items-center justify-center text-current')}>
{checked && <CheckIcon className="size-4" />}
</div>
</button>
</div>
);
}
function TodoLiStatic(props: SlateRenderElementProps) {
return (
<li
className={cn(
'list-none',
(props.element.checked as boolean) &&
'text-muted-foreground line-through'
)}
>
{props.children}
</li>
);
}

View File

@ -0,0 +1,87 @@
'use client';
import React from 'react';
import type { TListElement } from 'platejs';
import { isOrderedList } from '@platejs/list';
import {
useTodoListElement,
useTodoListElementState,
} from '@platejs/list/react';
import {
type PlateElementProps,
type RenderNodeWrapper,
useReadOnly,
} from 'platejs/react';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
const config: Record<
string,
{
Li: React.FC<PlateElementProps>;
Marker: React.FC<PlateElementProps>;
}
> = {
todo: {
Li: TodoLi,
Marker: TodoMarker,
},
};
export const BlockList: RenderNodeWrapper = (props) => {
if (!props.element.listStyleType) return;
return (props) => <List {...props} />;
};
function List(props: PlateElementProps) {
const { listStart, listStyleType } = props.element as TListElement;
const { Li, Marker } = config[listStyleType] ?? {};
const List = isOrderedList(props.element) ? 'ol' : 'ul';
return (
<List
className="relative m-0 p-0"
style={{ listStyleType }}
start={listStart}
>
{Marker && <Marker {...props} />}
{Li ? <Li {...props} /> : <li>{props.children}</li>}
</List>
);
}
function TodoMarker(props: PlateElementProps) {
const state = useTodoListElementState({ element: props.element });
const { checkboxProps } = useTodoListElement(state);
const readOnly = useReadOnly();
return (
<div contentEditable={false}>
<Checkbox
className={cn(
'absolute top-1 -left-6',
readOnly && 'pointer-events-none'
)}
{...checkboxProps}
/>
</div>
);
}
function TodoLi(props: PlateElementProps) {
return (
<li
className={cn(
'list-none',
(props.element.checked as boolean) &&
'text-muted-foreground line-through'
)}
>
{props.children}
</li>
);
}

View File

@ -0,0 +1,36 @@
import * as React from 'react';
import {
type SlateElementProps,
type SlateLeafProps,
type TCodeBlockElement,
SlateElement,
SlateLeaf,
} from 'platejs';
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} />;
}

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 gap-0.5 select-none"
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-xs text-muted-foreground"
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 justify-between gap-1 px-2 text-xs text-muted-foreground select-none"
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,32 @@
'use client';
import * as React from 'react';
import { useIndentButton, useOutdentButton } from '@platejs/indent/react';
import { IndentIcon, OutdentIcon } from 'lucide-react';
import { ToolbarButton } from './toolbar';
export function IndentToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const { props: buttonProps } = useIndentButton();
return (
<ToolbarButton {...props} {...buttonProps} tooltip="Indent">
<IndentIcon />
</ToolbarButton>
);
}
export function OutdentToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const { props: buttonProps } = useOutdentButton();
return (
<ToolbarButton {...props} {...buttonProps} tooltip="Outdent">
<OutdentIcon />
</ToolbarButton>
);
}

View File

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