diff --git a/bun.lockb b/bun.lockb index 6c3130b0..4bba695c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cspell.config.yaml b/cspell.config.yaml index 2a4927f6..eb4dd65e 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -13,20 +13,26 @@ ignorePaths: - tsconfig.tsbuildinfo - branch_out words: + - ANTLR - Arcjet + - atrule - callees - classname - clsx + - combobox - commitlint - compat + - Draggables + - esbenp + - excalidraw - frontmatter + - healthcheck - hookform - ianvs - ixahmedxi - ixahmedxii - jiti - lockb - - esbenp - lucide - mitigations - neondatabase @@ -34,8 +40,11 @@ words: - packagejson - paralleldrive - Posthog + - prismjs + - protobuf - ratelimit - Registrator + - Scroller - serviceworker - Shadcn - Signin @@ -45,12 +54,12 @@ words: - superjson - tailwindcss - tanstack + - timeago - Todos - trpc - tsbuildinfo - tseslint - typecheck + - udecode - Uploadthing - Upstash - - healthcheck - - timeago diff --git a/package.json b/package.json index 93a3b678..a945bf88 100644 --- a/package.json +++ b/package.json @@ -54,13 +54,14 @@ } }, "dependencies": { - "@clerk/nextjs": "^5.4.1", - "@clerk/themes": "^2.1.27", + "@clerk/nextjs": "^5.5.2", + "@clerk/themes": "^2.1.29", "@hookform/resolvers": "^3.9.0", "@neondatabase/serverless": "^0.9.5", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", @@ -70,12 +71,60 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.2", "@react-email/components": "^0.0.25", "@t3-oss/env-nextjs": "^0.11.1", - "@tanstack/react-query": "^5.54.1", + "@tanstack/react-query": "^5.56.2", "@trpc/client": "next", "@trpc/react-query": "next", "@trpc/server": "next", + "@udecode/cn": "^38.0.1", + "@udecode/plate-alignment": "^38.0.1", + "@udecode/plate-autoformat": "^38.0.1", + "@udecode/plate-basic-marks": "^38.0.1", + "@udecode/plate-block-quote": "^38.0.1", + "@udecode/plate-break": "^38.0.1", + "@udecode/plate-caption": "^38.0.1", + "@udecode/plate-code-block": "^38.0.1", + "@udecode/plate-combobox": "^38.0.1", + "@udecode/plate-comments": "^38.0.1", + "@udecode/plate-common": "^38.0.4", + "@udecode/plate-csv": "^38.0.1", + "@udecode/plate-cursor": "^38.0.0", + "@udecode/plate-date": "^38.0.1", + "@udecode/plate-dnd": "^38.0.0", + "@udecode/plate-docx": "^38.0.1", + "@udecode/plate-emoji": "^38.0.1", + "@udecode/plate-excalidraw": "^38.0.1", + "@udecode/plate-find-replace": "^38.0.0", + "@udecode/plate-floating": "^38.0.1", + "@udecode/plate-font": "^38.0.1", + "@udecode/plate-heading": "^38.0.1", + "@udecode/plate-highlight": "^38.0.1", + "@udecode/plate-horizontal-rule": "^38.0.1", + "@udecode/plate-html": "^38.0.1", + "@udecode/plate-indent": "^38.0.1", + "@udecode/plate-indent-list": "^38.0.1", + "@udecode/plate-juice": "^38.0.1", + "@udecode/plate-kbd": "^38.0.1", + "@udecode/plate-layout": "^38.0.1", + "@udecode/plate-line-height": "^38.0.1", + "@udecode/plate-link": "^38.0.1", + "@udecode/plate-list": "^38.0.1", + "@udecode/plate-markdown": "^38.0.1", + "@udecode/plate-media": "^38.0.1", + "@udecode/plate-mention": "^38.0.1", + "@udecode/plate-node-id": "^38.0.1", + "@udecode/plate-normalizers": "^38.0.1", + "@udecode/plate-paragraph": "^36.0.0", + "@udecode/plate-reset-node": "^38.0.1", + "@udecode/plate-resizable": "^38.0.0", + "@udecode/plate-select": "^38.0.1", + "@udecode/plate-selection": "^38.0.0", + "@udecode/plate-tabbable": "^38.0.1", + "@udecode/plate-table": "^38.0.1", + "@udecode/plate-toggle": "^38.0.1", + "@udecode/plate-trailing-block": "^38.0.1", "@upstash/redis": "^1.34.0", "@vercel/analytics": "^1.3.1", "@vercel/speed-insights": "^1.0.12", @@ -83,15 +132,18 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.33.0", "drizzle-zod": "^0.5.1", - "framer-motion": "^11.5.2", + "framer-motion": "^11.5.4", "geist": "^1.3.1", "jiti": "^1.21.6", - "lucide-react": "^0.438.0", - "next": "14.2.8", + "lucide-react": "^0.441.0", + "next": "14.2.11", "next-mdx-remote": "^5.0.0", "next-themes": "^0.3.0", + "prismjs": "^1.29.0", "react": "^18.3.1", "react-animate-height": "^3.2.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-email": "^3.0.1", "react-hook-form": "^7.53.0", @@ -106,40 +158,41 @@ }, "devDependencies": { "@changesets/cli": "^2.27.8", - "@commitlint/cli": "^19.4.1", - "@commitlint/config-conventional": "^19.4.1", - "@commitlint/cz-commitlint": "^19.4.0", + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@commitlint/cz-commitlint": "^19.5.0", "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", "@eslint/compat": "^1.1.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "^9.9.1", - "@happy-dom/global-registrator": "^15.7.3", + "@eslint/js": "^9.10.0", + "@happy-dom/global-registrator": "^15.7.4", "@ianvs/prettier-plugin-sort-imports": "^4.3.1", - "@next/eslint-plugin-next": "^14.2.8", + "@next/eslint-plugin-next": "^14.2.11", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@total-typescript/ts-reset": "^0.6.1", - "@types/bun": "^1.1.8", + "@types/bun": "^1.1.9", "@types/eslint": "^9.6.1", "@types/eslint-config-prettier": "^6.11.3", - "@types/node": "^22.5.4", + "@types/node": "^22.5.5", + "@types/prismjs": "^1.26.4", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "commitizen": "^4.3.0", "cspell": "^8.14.2", "drizzle-kit": "^0.24.2", - "eslint": "^9.9.1", + "eslint": "^9.10.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jsdoc": "^50.2.2", + "eslint-plugin-jsdoc": "^50.2.3", "eslint-plugin-jsx-a11y": "^6.10.0", - "eslint-plugin-react": "^7.35.2", + "eslint-plugin-react": "^7.36.1", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-regexp": "^2.6.0", "eslint-plugin-security": "^3.0.1", "eslint-plugin-tailwindcss": "^3.17.4", "globals": "^15.9.0", - "husky": "^9.1.5", + "husky": "^9.1.6", "lint-staged": "^15.2.10", "markdownlint": "^0.35.0", "markdownlint-cli": "^0.41.0", @@ -147,10 +200,10 @@ "prettier": "^3.3.3", "prettier-plugin-curly": "^0.2.2", "prettier-plugin-packagejson": "^2.5.2", - "tailwindcss": "^3.4.10", + "tailwindcss": "^3.4.11", "tailwindcss-animate": "^1.0.7", - "typescript": "^5.5.4", - "typescript-eslint": "^8.4.0" + "typescript": "^5.6.2", + "typescript-eslint": "^8.5.0" }, "trustedDependencies": [ "@clerk/shared", diff --git a/src/app/notebook/[id]/page.tsx b/src/app/notebook/[id]/page.tsx new file mode 100644 index 00000000..16c21f80 --- /dev/null +++ b/src/app/notebook/[id]/page.tsx @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic'; + +const PlateEditor = dynamic( + () => import('@/editor').then((res) => res.PlateEditor), + { ssr: false }, +); + +export default function NotebookPage() { + return ( +
+ +
+ ); +} diff --git a/src/editor/index.tsx b/src/editor/index.tsx new file mode 100644 index 00000000..8c922ab9 --- /dev/null +++ b/src/editor/index.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { + createPlateEditor, + ParagraphPlugin, + Plate, +} from '@udecode/plate-common/react'; +import { Editor } from './ui/editor'; +import { createPlateUI } from './ui/components'; +import { plugins } from './plugins'; +import { HEADING_KEYS } from '@udecode/plate-heading'; +import type { Value } from '@udecode/plate-common'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +const initialValue = [ + { + type: HEADING_KEYS.h1, + children: [{ text: '' }], + }, + { + type: ParagraphPlugin.key, + children: [{ text: '' }], + }, +]; + +export const PlateEditor = () => { + const localValue = + typeof window !== 'undefined' && localStorage.getItem('editorContent'); + + const editor = createPlateEditor({ + value: localValue ? (JSON.parse(localValue) as Value) : initialValue, + plugins, + override: { + components: createPlateUI(), + }, + }); + + return ( + + { + console.log(value); + localStorage.setItem('editorContent', JSON.stringify(value)); + }} + > + + + + ); +}; diff --git a/src/editor/plugins/autoformat-rules.ts b/src/editor/plugins/autoformat-rules.ts new file mode 100644 index 00000000..4d3e59ee --- /dev/null +++ b/src/editor/plugins/autoformat-rules.ts @@ -0,0 +1,341 @@ +import { + autoformatArrow, + autoformatLegal, + autoformatLegalHtml, + autoformatMath, + autoformatPunctuation, + autoformatSmartQuotes, +} from '@udecode/plate-autoformat'; +import { + BoldPlugin, + CodePlugin, + ItalicPlugin, + StrikethroughPlugin, + SubscriptPlugin, + SuperscriptPlugin, + UnderlinePlugin, +} from '@udecode/plate-basic-marks/react'; +import { BlockquotePlugin } from '@udecode/plate-block-quote/react'; +import { insertEmptyCodeBlock } from '@udecode/plate-code-block'; +import { + CodeBlockPlugin, + CodeLinePlugin, +} from '@udecode/plate-code-block/react'; +import { + getParentNode, + insertNodes, + isBlock, + isElement, + isType, + setNodes, +} from '@udecode/plate-common'; +import { ParagraphPlugin } from '@udecode/plate-common/react'; +import { HEADING_KEYS } from '@udecode/plate-heading'; +import { HighlightPlugin } from '@udecode/plate-highlight/react'; +import { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react'; +import { + INDENT_LIST_KEYS, + ListStyleType, + toggleIndentList, +} from '@udecode/plate-indent-list'; +import { toggleList, unwrapList } from '@udecode/plate-list'; +import { + BulletedListPlugin, + ListItemPlugin, + NumberedListPlugin, + TodoListPlugin, +} from '@udecode/plate-list/react'; +import { openNextToggles, TogglePlugin } from '@udecode/plate-toggle/react'; + +import type { + AutoformatBlockRule, + AutoformatRule, +} from '@udecode/plate-autoformat'; +import type { SlateEditor } from '@udecode/plate-common'; +import type { TTodoListItemElement } from '@udecode/plate-list'; + +export const preFormat: AutoformatBlockRule['preFormat'] = (editor) => { + unwrapList(editor); +}; + +export const format = (editor: SlateEditor, customFormatting: () => void) => { + if (editor.selection) { + const parentEntry = getParentNode(editor, editor.selection); + + if (!parentEntry) { + return; + } + + const [node] = parentEntry; + + if ( + isElement(node) && + !isType(editor, node, CodeBlockPlugin.key) && + !isType(editor, node, CodeLinePlugin.key) + ) { + customFormatting(); + } + } +}; + +export const formatList = (editor: SlateEditor, elementType: string) => { + format(editor, () => + toggleList(editor, { + type: elementType, + }), + ); +}; + +export const autoformatMarks: AutoformatRule[] = [ + { + match: '***', + mode: 'mark', + type: [BoldPlugin.key, ItalicPlugin.key], + }, + { + match: '__**', + mode: 'mark', + type: [UnderlinePlugin.key, BoldPlugin.key], + }, + { + match: '__*', + mode: 'mark', + type: [UnderlinePlugin.key, ItalicPlugin.key], + }, + { + match: '___***', + mode: 'mark', + type: [UnderlinePlugin.key, BoldPlugin.key, ItalicPlugin.key], + }, + { + match: '**', + mode: 'mark', + type: BoldPlugin.key, + }, + { + match: '__', + mode: 'mark', + type: UnderlinePlugin.key, + }, + { + match: '*', + mode: 'mark', + type: ItalicPlugin.key, + }, + { + match: '_', + mode: 'mark', + type: ItalicPlugin.key, + }, + { + match: '~~', + mode: 'mark', + type: StrikethroughPlugin.key, + }, + { + match: '^', + mode: 'mark', + type: SuperscriptPlugin.key, + }, + { + match: '~', + mode: 'mark', + type: SubscriptPlugin.key, + }, + { + match: '==', + mode: 'mark', + type: HighlightPlugin.key as string, + }, + { + match: '≡', + mode: 'mark', + type: HighlightPlugin.key as string, + }, + { + match: '`', + mode: 'mark', + type: CodePlugin.key, + }, +]; + +export const autoformatBlocks: AutoformatRule[] = [ + { + match: '# ', + mode: 'block', + preFormat, + type: HEADING_KEYS.h1, + }, + { + match: '## ', + mode: 'block', + preFormat, + type: HEADING_KEYS.h2, + }, + { + match: '### ', + mode: 'block', + preFormat, + type: HEADING_KEYS.h3, + }, + { + match: '#### ', + mode: 'block', + preFormat, + type: HEADING_KEYS.h4, + }, + { + match: '##### ', + mode: 'block', + preFormat, + type: HEADING_KEYS.h5, + }, + { + match: '###### ', + mode: 'block', + preFormat, + type: HEADING_KEYS.h6, + }, + { + match: '> ', + mode: 'block', + preFormat, + type: BlockquotePlugin.key, + }, + { + format: (editor) => { + insertEmptyCodeBlock(editor, { + defaultType: ParagraphPlugin.key, + insertNodesOptions: { select: true }, + }); + }, + match: '```', + mode: 'block', + preFormat, + triggerAtBlockStart: false, + type: CodeBlockPlugin.key, + }, + { + match: '+ ', + mode: 'block', + preFormat: openNextToggles, + type: TogglePlugin.key, + }, + { + format: (editor) => { + setNodes(editor, { type: HorizontalRulePlugin.key }); + insertNodes(editor, { + children: [{ text: '' }], + type: ParagraphPlugin.key, + }); + }, + match: ['---', '—-', '___ '], + mode: 'block', + type: HorizontalRulePlugin.key, + }, +]; + +export const autoformatLists: AutoformatRule[] = [ + { + format: (editor) => { + formatList(editor, BulletedListPlugin.key); + }, + match: ['* ', '- '], + mode: 'block', + preFormat, + type: ListItemPlugin.key, + }, + { + format: (editor) => { + formatList(editor, NumberedListPlugin.key); + }, + match: ['^\\d+\\.$ ', '^\\d+\\)$ '], + matchByRegex: true, + mode: 'block', + preFormat, + type: ListItemPlugin.key, + }, + { + match: '[] ', + mode: 'block', + type: TodoListPlugin.key as string, + }, + { + format: (editor) => { + setNodes( + editor, + { checked: true, type: TodoListPlugin.key }, + { + match: (n) => isBlock(editor, n), + }, + ); + }, + match: '[x] ', + mode: 'block', + type: TodoListPlugin.key as string, + }, +]; + +export const autoformatIndentLists: AutoformatRule[] = [ + { + format: (editor) => { + toggleIndentList(editor, { + listStyleType: ListStyleType.Disc, + }); + }, + match: ['* ', '- '], + mode: 'block', + type: 'list', + }, + { + format: (editor) => { + toggleIndentList(editor, { + listStyleType: ListStyleType.Decimal, + }); + }, + match: ['^\\d+\\.$ ', '^\\d+\\)$ '], + matchByRegex: true, + mode: 'block', + type: 'list', + }, + { + format: (editor) => { + toggleIndentList(editor, { + listStyleType: INDENT_LIST_KEYS.todo, + }); + setNodes(editor, { + checked: false, + listStyleType: INDENT_LIST_KEYS.todo, + }); + }, + match: ['[] '], + mode: 'block', + type: 'list', + }, + { + format: (editor) => { + toggleIndentList(editor, { + listStyleType: INDENT_LIST_KEYS.todo, + }); + setNodes(editor, { + checked: true, + listStyleType: INDENT_LIST_KEYS.todo, + }); + }, + match: ['[x] '], + mode: 'block', + type: 'list', + }, +]; + +export const autoformatRules: AutoformatRule[] = [ + ...autoformatBlocks, + ...autoformatMarks, + ...autoformatSmartQuotes, + ...autoformatPunctuation, + ...autoformatLegal, + ...autoformatLegalHtml, + ...autoformatArrow, + ...autoformatMath, + ...autoformatIndentLists, +]; diff --git a/src/editor/plugins/index.tsx b/src/editor/plugins/index.tsx new file mode 100644 index 00000000..c447e767 --- /dev/null +++ b/src/editor/plugins/index.tsx @@ -0,0 +1,265 @@ +import { + BoldPlugin, + CodePlugin, + ItalicPlugin, + StrikethroughPlugin, + SubscriptPlugin, + SuperscriptPlugin, + UnderlinePlugin, +} from '@udecode/plate-basic-marks/react'; +import { AlignPlugin } from '@udecode/plate-alignment/react'; +import { autoformatRules } from './autoformat-rules'; +import { SelectOnBackspacePlugin } from '@udecode/plate-select'; +import { JuicePlugin } from '@udecode/plate-juice'; +import { DocxPlugin } from '@udecode/plate-docx'; +import { MarkdownPlugin } from '@udecode/plate-markdown'; +import { TabbablePlugin } from '@udecode/plate-tabbable/react'; +import { ResetNodePlugin } from '@udecode/plate-reset-node/react'; +import { ImagePlugin } from '@udecode/plate-media/react'; +import { ExitBreakPlugin, SoftBreakPlugin } from '@udecode/plate-break/react'; +import { AutoformatPlugin } from '@udecode/plate-autoformat/react'; +import { DndPlugin } from '@udecode/plate-dnd'; +import { + isBlockAboveEmpty, + isSelectionAtBlockStart, + someNode, +} from '@udecode/plate-common'; +import { ParagraphPlugin } from '@udecode/plate-common/react'; +import { NodeIdPlugin } from '@udecode/plate-node-id'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; +import { TrailingBlockPlugin } from '@udecode/plate-trailing-block'; +import { HEADING_KEYS, HEADING_LEVELS } from '@udecode/plate-heading'; +import { HeadingPlugin } from '@udecode/plate-heading/react'; +import { IndentPlugin } from '@udecode/plate-indent/react'; +import { BlockquotePlugin } from '@udecode/plate-block-quote/react'; +import { NormalizeTypesPlugin } from '@udecode/plate-normalizers'; +import { + isCodeBlockEmpty, + isSelectionAtCodeBlockStart, + unwrapCodeBlock, +} from '@udecode/plate-code-block'; +import { + CodeBlockPlugin, + CodeLinePlugin, + CodeSyntaxPlugin, +} from '@udecode/plate-code-block/react'; +import { LineHeightPlugin } from '@udecode/plate-line-height/react'; +import { IndentListPlugin } from '@udecode/plate-indent-list/react'; +import { + TableCellHeaderPlugin, + TableCellPlugin, + TablePlugin, + TableRowPlugin, +} from '@udecode/plate-table/react'; +import { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react'; +import { TodoListPlugin } from '@udecode/plate-list/react'; +import Prism from 'prismjs'; +import { LinkPlugin } from '@udecode/plate-link/react'; +import { LinkFloatingToolbar } from '../ui/link-floating-toolbar'; +import { TodoLi, TodoMarker } from '../ui/elements/todo'; +import { HighlightPlugin } from '@udecode/plate-highlight/react'; + +export const plugins = [ + // Nodes + HeadingPlugin, + BlockquotePlugin, + CodeBlockPlugin.configure({ + options: { + syntax: true, + prism: Prism, + }, + }), + CodeLinePlugin, + CodeSyntaxPlugin, + HorizontalRulePlugin, + TablePlugin, + TableRowPlugin, + TableCellPlugin, + TableCellHeaderPlugin, + TodoListPlugin, + LinkPlugin.configure({ + render: { afterEditable: () => }, + }), + + // Basic Marks + BoldPlugin, + ItalicPlugin, + UnderlinePlugin, + StrikethroughPlugin, + CodePlugin, + SubscriptPlugin, + SuperscriptPlugin, + HighlightPlugin, + + // Block Style + AlignPlugin.configure({ + inject: { + targetPlugins: [ParagraphPlugin.key, ...HEADING_LEVELS], + }, + }), + IndentPlugin.configure({ + inject: { + targetPlugins: [ + ParagraphPlugin.key, + BlockquotePlugin.key, + CodeBlockPlugin.key, + ...HEADING_LEVELS, + ], + }, + }), + IndentListPlugin.configure({ + inject: { + targetPlugins: [ + ParagraphPlugin.key, + BlockquotePlugin.key, + CodeBlockPlugin.key, + ...HEADING_LEVELS, + ], + }, + options: { + listStyleTypes: { + todo: { + liComponent: TodoLi, + markerComponent: TodoMarker, + type: 'todo', + }, + }, + }, + }), + LineHeightPlugin.configure({ + inject: { + nodeProps: { + defaultNodeValue: 1.5, + validNodeValues: [1, 1.2, 1.5, 2, 3], + }, + targetPlugins: [ParagraphPlugin.key, ...HEADING_LEVELS], + }, + }), + + // Functionality + AutoformatPlugin.configure({ + options: { + rules: autoformatRules, + enableUndoOnDelete: true, + }, + }), + BlockSelectionPlugin, + ExitBreakPlugin.configure({ + options: { + rules: [ + { + hotkey: 'mod+enter', + }, + { + hotkey: 'mod+shift+enter', + before: true, + }, + { + hotkey: 'enter', + query: { + start: true, + end: true, + allow: HEADING_LEVELS, + }, + relative: true, + level: 1, + }, + ], + }, + }), + NodeIdPlugin, + DndPlugin.configure({ options: { enableScroller: true } }), + ResetNodePlugin.configure({ + options: { + rules: [ + { + types: [BlockquotePlugin.key, TodoListPlugin.key as string], + defaultType: ParagraphPlugin.key, + hotkey: 'Enter', + predicate: isBlockAboveEmpty, + }, + { + types: [BlockquotePlugin.key, TodoListPlugin.key as string], + defaultType: ParagraphPlugin.key, + hotkey: 'Backspace', + predicate: isSelectionAtBlockStart, + }, + { + types: [CodeBlockPlugin.key], + defaultType: ParagraphPlugin.key, + onReset: unwrapCodeBlock, + hotkey: 'Enter', + predicate: isCodeBlockEmpty, + }, + { + types: [CodeBlockPlugin.key], + defaultType: ParagraphPlugin.key, + onReset: unwrapCodeBlock, + hotkey: 'Backspace', + predicate: isSelectionAtCodeBlockStart, + }, + ], + }, + }), + SelectOnBackspacePlugin.configure({ + options: { + query: { + allow: [ImagePlugin.key, HorizontalRulePlugin.key], + }, + }, + }), + SoftBreakPlugin.configure({ + options: { + rules: [ + { hotkey: 'shift+enter' }, + { + hotkey: 'enter', + query: { + allow: [ + CodeBlockPlugin.key, + BlockquotePlugin.key, + TableCellPlugin.key, + TableCellHeaderPlugin.key, + ], + }, + }, + ], + }, + }), + TabbablePlugin.configure(({ editor }) => ({ + options: { + query: () => { + if (isSelectionAtBlockStart(editor)) { + return false; + } + + return !someNode(editor, { + match: (n) => { + return !!( + n.type && + ([ + TablePlugin.key, + TodoListPlugin.key, + CodeBlockPlugin.key, + ].includes(n.type as string) || + n['listStyleType']) + ); + }, + }); + }, + }, + })), + TrailingBlockPlugin.configure({ + options: { type: ParagraphPlugin.key }, + }), + NormalizeTypesPlugin.configure({ + options: { + rules: [{ path: [0], strictType: HEADING_KEYS.h1 }], + }, + }), + + // Deserialization + DocxPlugin, + MarkdownPlugin, + JuicePlugin, +]; diff --git a/src/editor/ui/components.tsx b/src/editor/ui/components.tsx new file mode 100644 index 00000000..0d7e3f80 --- /dev/null +++ b/src/editor/ui/components.tsx @@ -0,0 +1,102 @@ +import { withProps } from '@udecode/cn'; +import { + BoldPlugin, + CodePlugin, + ItalicPlugin, + StrikethroughPlugin, + SubscriptPlugin, + SuperscriptPlugin, + UnderlinePlugin, +} from '@udecode/plate-basic-marks/react'; +import type { NodeComponent } from '@udecode/plate-common/react'; +import { + ParagraphPlugin, + PlateElement, + PlateLeaf, +} from '@udecode/plate-common/react'; +import { HEADING_KEYS } from '@udecode/plate-heading'; +import { HeadingElement } from './elements/heading'; +import { BlockquotePlugin } from '@udecode/plate-block-quote/react'; +import { BlockquoteElement } from './elements/blockquote'; +import { withPlaceholders } from './placeholder'; +import { + CodeBlockPlugin, + CodeLinePlugin, + CodeSyntaxPlugin, +} from '@udecode/plate-code-block/react'; +import { CodeBlockElement } from './elements/code-block'; +import { CodeLineElement } from './elements/code-block/code-line-element'; +import { CodeSyntaxLeaf } from './elements/code-block/code-syntax-leaf'; +import { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react'; +import { HrElement } from './elements/hr'; +import { TodoListPlugin } from '@udecode/plate-list/react'; +import { TodoListElement } from './elements/todo-list'; +import { LinkPlugin } from '@udecode/plate-link/react'; +import { LinkElement } from './elements/link'; +import { HighlightPlugin } from '@udecode/plate-highlight/react'; +import { withDraggables } from './with-draggables'; + +export const createPlateUI = () => { + let components: Record = { + [HEADING_KEYS.h1]: withProps(HeadingElement, { variant: 'h1' }), + [HEADING_KEYS.h2]: withProps(HeadingElement, { variant: 'h2' }), + [HEADING_KEYS.h3]: withProps(HeadingElement, { variant: 'h3' }), + [HEADING_KEYS.h4]: withProps(HeadingElement, { variant: 'h4' }), + [HEADING_KEYS.h5]: withProps(HeadingElement, { variant: 'h5' }), + [HEADING_KEYS.h6]: withProps(HeadingElement, { variant: 'h6' }), + + [BlockquotePlugin.key]: BlockquoteElement, + + [CodeBlockPlugin.key]: CodeBlockElement, + [CodeLinePlugin.key]: CodeLineElement, + [CodeSyntaxPlugin.key]: CodeSyntaxLeaf, + + [HorizontalRulePlugin.key]: HrElement, + [TodoListPlugin.key]: TodoListElement, + [LinkPlugin.key]: LinkElement, + + [ParagraphPlugin.key]: withProps(PlateElement, { + draggable: true, + as: 'p', + className: 'text-foreground-muted leading-7 m-0 px-0', + }), + [BoldPlugin.key]: withProps(PlateLeaf, { + as: 'strong', + className: '!font-semibold text-foreground', + }), + [ItalicPlugin.key]: withProps(PlateLeaf, { + as: 'em', + className: 'text-foreground', + }), + [UnderlinePlugin.key]: withProps(PlateLeaf, { + as: 'u', + className: 'text-foreground', + }), + [StrikethroughPlugin.key]: withProps(PlateLeaf, { + as: 's', + className: 'text-foreground', + }), + [SubscriptPlugin.key]: withProps(PlateLeaf, { + as: 'sub', + className: 'text-foreground', + }), + [SuperscriptPlugin.key]: withProps(PlateLeaf, { + as: 'sup', + className: 'text-foreground', + }), + [CodePlugin.key]: withProps(PlateLeaf, { + as: 'code', + className: + 'text-foreground bg-gray-element border rounded-md px-1 py-0.5', + }), + [HighlightPlugin.key]: withProps(PlateLeaf, { + as: 'mark', + className: + 'bg-yellow-500/10 text-yellow-500 [&>em]:text-yellow-500 [&>strong]:text-yellow-500 [&>u]:text-yellow-500 [&>s]:text-yellow-500 [&>sub]:text-yellow-500 [&>sup]:text-yellow-500', + }), + }; + + components = withPlaceholders(withDraggables(components)); + + return components; +}; diff --git a/src/editor/ui/draggable.tsx b/src/editor/ui/draggable.tsx new file mode 100644 index 00000000..10523c0e --- /dev/null +++ b/src/editor/ui/draggable.tsx @@ -0,0 +1,174 @@ +'use client'; + +import React from 'react'; + +import type { ClassNames, TEditor } from '@udecode/plate-common'; +import type { DropTargetMonitor } from 'react-dnd'; + +import { cn, withRef } from '@udecode/cn'; +import { + type PlateElementProps, + useEditorRef, +} from '@udecode/plate-common/react'; +import { + type DragItemNode, + useDraggable, + useDraggableState, +} from '@udecode/plate-dnd'; +import { BlockSelectionPlugin } from '@udecode/plate-selection/react'; + +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from '@/primitives/tooltip'; +import { GripIcon } from 'lucide-react'; + +export interface DraggableProps + extends PlateElementProps, + ClassNames<{ + /** Block. */ + block: string; + + /** Block and gutter. */ + blockAndGutter: string; + + /** Block toolbar in the gutter. */ + blockToolbar: string; + + /** + * Block toolbar wrapper in the gutter left. It has the height of a line + * of the block. + */ + blockToolbarWrapper: string; + + blockWrapper: string; + + /** Button to dnd the block, in the block toolbar. */ + dragHandle: string; + + /** Icon of the drag button, in the drag icon. */ + dragIcon: string; + + /** Show a dropline above or below the block when dragging a block. */ + dropLine: string; + + /** Gutter at the left side of the editor. It has the height of the block */ + gutterLeft: string; + }> { + /** + * Intercepts the drop handling. If `false` is returned, the default drop + * behavior is called after. If `true` is returned, the default behavior is + * not called. + */ + onDropHandler?: ( + editor: TEditor, + props: { + id: string; + dragItem: DragItemNode; + monitor: DropTargetMonitor; + nodeRef: unknown; + }, + ) => boolean; + key: string; + keys: string[]; +} + +const DragHandle = () => { + const editor = useEditorRef(); + + return ( + + + + { + event.stopPropagation(); + event.preventDefault(); + }} + onMouseDown={() => { + editor + .getApi(BlockSelectionPlugin) + .blockSelection.resetSelectedIds(); + }} + /> + + + Drag to move + + + + ); +}; + +export const Draggable = withRef<'div', DraggableProps>( + ({ className, classNames = {}, children, element, onDropHandler }, ref) => { + const state = useDraggableState({ element, onDropHandler }); + const { dropLine, isDragging, isHovered } = state; + const { + droplineProps, + groupProps, + gutterLeftProps, + previewRef, + handleRef, + } = useDraggable(state); + + return ( +
+
+
+
+
+ {isHovered && } +
+
+
+
+ +
+ {children} + + {!!dropLine && ( +
+ )} +
+
+ ); + }, +); diff --git a/src/editor/ui/editor.tsx b/src/editor/ui/editor.tsx new file mode 100644 index 00000000..b305ba8d --- /dev/null +++ b/src/editor/ui/editor.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { cn } from '@udecode/cn'; +import { PlateContent } from '@udecode/plate-common/react'; +import { cva } from 'class-variance-authority'; + +import type { PlateContentProps } from '@udecode/plate-common/react'; +import type { VariantProps } from 'class-variance-authority'; + +const editorVariants = cva( + cn( + 'relative whitespace-pre-wrap break-words', + 'min-h-[80px] w-full rounded-md text-sm ring-offset-background placeholder:text-foreground-muted focus-visible:outline-none', + '[&_[data-slate-placeholder]]:text-foreground-muted [&_[data-slate-placeholder]]:!opacity-100', + '[&_[data-slate-placeholder]]:top-[auto_!important]', + '[&_strong]:font-bold', + ), + { + defaultVariants: { + focusRing: true, + size: 'sm', + variant: 'outline', + }, + variants: { + disabled: { + true: 'cursor-not-allowed opacity-50', + }, + focusRing: { + false: '', + true: 'focus-visible:ring-2 focus-visible:ring-pink-solid-hover focus-visible:ring-offset-2', + }, + focused: { + true: 'ring-2 ring-pink-solid-hover ring-offset-2', + }, + size: { + md: 'text-base', + sm: 'text-sm', + }, + variant: { + ghost: '', + outline: 'border', + }, + }, + }, +); + +export type EditorProps = PlateContentProps & + VariantProps; + +const Editor = React.forwardRef( + ( + { + className, + disabled, + focusRing, + focused, + readOnly = false, + size = 'md', + variant, + ...props + }, + ref, + ) => { + return ( +
+ +
+ ); + }, +); +Editor.displayName = 'Editor'; + +export { Editor }; diff --git a/src/editor/ui/elements/blockquote.tsx b/src/editor/ui/elements/blockquote.tsx new file mode 100644 index 00000000..8c979f90 --- /dev/null +++ b/src/editor/ui/elements/blockquote.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React from 'react'; + +import { cn, withRef } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; + +export const BlockquoteElement = withRef( + ({ children, className, ...props }, ref) => { + return ( + +
{children}
+
+ ); + }, +); diff --git a/src/editor/ui/elements/code-block/code-block-combobox.tsx b/src/editor/ui/elements/code-block/code-block-combobox.tsx new file mode 100644 index 00000000..a6ab81df --- /dev/null +++ b/src/editor/ui/elements/code-block/code-block-combobox.tsx @@ -0,0 +1,211 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Prism from 'prismjs'; + +import { Icon } from '@/primitives/icon'; + +import { Button } from '@/primitives/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/primitives/popover'; + +import 'prismjs/components/prism-antlr4.js'; +import 'prismjs/components/prism-bash.js'; +import 'prismjs/components/prism-c.js'; +import 'prismjs/components/prism-cmake.js'; +import 'prismjs/components/prism-coffeescript.js'; +import 'prismjs/components/prism-cpp.js'; +import 'prismjs/components/prism-csharp.js'; +import 'prismjs/components/prism-css.js'; +import 'prismjs/components/prism-dart.js'; +import 'prismjs/components/prism-django.js'; +import 'prismjs/components/prism-docker.js'; +import 'prismjs/components/prism-ejs.js'; +import 'prismjs/components/prism-erlang.js'; +import 'prismjs/components/prism-git.js'; +import 'prismjs/components/prism-go.js'; +import 'prismjs/components/prism-graphql.js'; +import 'prismjs/components/prism-groovy.js'; +import 'prismjs/components/prism-java.js'; +import 'prismjs/components/prism-javascript.js'; +import 'prismjs/components/prism-json.js'; +import 'prismjs/components/prism-jsx.js'; +import 'prismjs/components/prism-kotlin.js'; +import 'prismjs/components/prism-latex.js'; +import 'prismjs/components/prism-less.js'; +import 'prismjs/components/prism-lua.js'; +import 'prismjs/components/prism-makefile.js'; +import 'prismjs/components/prism-markdown.js'; +import 'prismjs/components/prism-matlab.js'; +import 'prismjs/components/prism-mermaid.js'; +import 'prismjs/components/prism-objectivec.js'; +import 'prismjs/components/prism-perl.js'; +import 'prismjs/components/prism-php.js'; +import 'prismjs/components/prism-powershell.js'; +import 'prismjs/components/prism-properties.js'; +import 'prismjs/components/prism-protobuf.js'; +import 'prismjs/components/prism-python.js'; +import 'prismjs/components/prism-r.js'; +import 'prismjs/components/prism-ruby.js'; +import 'prismjs/components/prism-sass.js'; +import 'prismjs/components/prism-scala.js'; +import 'prismjs/components/prism-scheme.js'; +import 'prismjs/components/prism-scss.js'; +import 'prismjs/components/prism-sql.js'; +import 'prismjs/components/prism-swift.js'; +import 'prismjs/components/prism-tsx.js'; +import 'prismjs/components/prism-typescript.js'; +import 'prismjs/components/prism-wasm.js'; +import 'prismjs/components/prism-yaml.js'; +import { ScrollArea } from '@/primitives/scroll-area'; +import { + useCodeBlockCombobox, + useCodeBlockComboboxState, +} from '@udecode/plate-code-block/react'; +import { cn } from '@/lib/utils'; +import { Input } from '@/primitives/input'; + +export { Prism }; + +const languages: { label: string; value: string }[] = [ + { label: 'Plain Text', value: 'text' }, + { label: 'Bash', value: 'bash' }, + { label: 'CSS', value: 'css' }, + { label: 'Git', value: 'git' }, + { label: 'GraphQL', value: 'graphql' }, + { label: 'HTML', value: 'html' }, + { label: 'JavaScript', value: 'javascript' }, + { label: 'JSON', value: 'json' }, + { label: 'JSX', value: 'jsx' }, + { label: 'Markdown', value: 'markdown' }, + { label: 'SQL', value: 'sql' }, + { label: 'SVG', value: 'svg' }, + { label: 'TSX', value: 'tsx' }, + { label: 'TypeScript', value: 'typescript' }, + { label: 'WebAssembly', value: 'wasm' }, + { label: 'ANTLR4', value: 'antlr4' }, + { label: 'C', value: 'c' }, + { label: 'CMake', value: 'cmake' }, + { label: 'CoffeeScript', value: 'coffeescript' }, + { label: 'C#', value: 'csharp' }, + { label: 'Dart', value: 'dart' }, + { label: 'Django', value: 'django' }, + { label: 'Docker', value: 'docker' }, + { label: 'EJS', value: 'ejs' }, + { label: 'Erlang', value: 'erlang' }, + { label: 'Go', value: 'go' }, + { label: 'Groovy', value: 'groovy' }, + { label: 'Java', value: 'java' }, + { label: 'Kotlin', value: 'kotlin' }, + { label: 'LaTeX', value: 'latex' }, + { label: 'Less', value: 'less' }, + { label: 'Lua', value: 'lua' }, + { label: 'Makefile', value: 'makefile' }, + { label: 'Markup', value: 'markup' }, + { label: 'MATLAB', value: 'matlab' }, + { label: 'Mermaid', value: 'mermaid' }, + { label: 'Objective-C', value: 'objectivec' }, + { label: 'Perl', value: 'perl' }, + { label: 'PHP', value: 'php' }, + { label: 'PowerShell', value: 'powershell' }, + { label: '.properties', value: 'properties' }, + { label: 'Protocol Buffers', value: 'protobuf' }, + { label: 'Python', value: 'python' }, + { label: 'R', value: 'r' }, + { label: 'Ruby', value: 'ruby' }, + { label: 'Sass (Sass)', value: 'sass' }, + { label: 'Scala', value: 'scala' }, + { label: 'Scheme', value: 'scheme' }, + { label: 'Sass (Scss)', value: 'scss' }, + { label: 'Shell', value: 'shell' }, + { label: 'Swift', value: 'swift' }, + { label: 'XML', value: 'xml' }, + { label: 'YAML', value: 'yaml' }, +]; + +export function CodeBlockCombobox() { + const state = useCodeBlockComboboxState(); + const { commandItemProps } = useCodeBlockCombobox(state); + + const [open, setOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [filteredLanguages, setFilteredLanguages] = useState(languages); + + useEffect(() => { + const filtered = languages.filter((lang) => + lang.label.toLowerCase().includes(searchTerm.toLowerCase()), + ); + setFilteredLanguages(filtered); + }, [searchTerm]); + + if (state.readOnly) { + return null; + } + + const handleSelect = (value: string) => { + commandItemProps.onSelect(value); + setOpen(false); + }; + + const selectedLanguage = + languages.find((lang) => lang.value === state.value) ?? languages[0]; + + return ( + + + + + +
+ { + setSearchTerm(e.target.value); + }} + /> +
+ + {filteredLanguages.length === 0 ? ( +
+ No language found. +
+ ) : ( + filteredLanguages.map((language) => ( + + )) + )} +
+
+
+ ); +} diff --git a/src/editor/ui/elements/code-block/code-block.css b/src/editor/ui/elements/code-block/code-block.css new file mode 100644 index 00000000..0a521eb1 --- /dev/null +++ b/src/editor/ui/elements/code-block/code-block.css @@ -0,0 +1,276 @@ +pre[class*='language-'], +code[class*='language-'] { + color: #d4d4d4; + font-size: 13px; + text-shadow: none; + font-family: Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono', + 'Courier New', monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +pre[class*='language-']::selection, +code[class*='language-']::selection, +pre[class*='language-'] *::selection, +code[class*='language-'] *::selection { + text-shadow: none; + background: #264f78; +} + +@media print { + pre[class*='language-'], + code[class*='language-'] { + text-shadow: none; + } +} + +pre[class*='language-'] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + background: #1e1e1e; +} + +:not(pre) > code[class*='language-'] { + padding: 0.1em 0.3em; + border-radius: 0.3em; + color: #db4c69; + background: #1e1e1e; +} +/********************************************************* +* Tokens +*/ +.namespace { + opacity: 0.7; +} + +.token.doctype .token.doctype-tag { + color: #569cd6; +} + +.token.doctype .token.name { + color: #9cdcfe; +} + +.token.comment, +.token.prolog { + color: #6a9955; +} + +.token.punctuation, +.language-html .language-css .token.punctuation, +.language-html .language-javascript .token.punctuation { + color: #d4d4d4; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.inserted, +.token.unit { + color: #b5cea8; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.deleted { + color: #ce9178; +} + +.language-css .token.string.url { + text-decoration: underline; +} + +.token.operator, +.token.entity { + color: #d4d4d4; +} + +.token.operator.arrow { + color: #569cd6; +} + +.token.atrule { + color: #ce9178; +} + +.token.atrule .token.rule { + color: #c586c0; +} + +.token.atrule .token.url { + color: #9cdcfe; +} + +.token.atrule .token.url .token.function { + color: #dcdcaa; +} + +.token.atrule .token.url .token.punctuation { + color: #d4d4d4; +} + +.token.keyword { + color: #569cd6; +} + +.token.keyword.module, +.token.keyword.control-flow { + color: #c586c0; +} + +.token.function, +.token.function .token.maybe-class-name { + color: #dcdcaa; +} + +.token.regex { + color: #d16969; +} + +.token.important { + color: #569cd6; +} + +.token.italic { + font-style: italic; +} + +.token.constant { + color: #9cdcfe; +} + +.token.class-name, +.token.maybe-class-name { + color: #4ec9b0; +} + +.token.console { + color: #9cdcfe; +} + +.token.parameter { + color: #9cdcfe; +} + +.token.interpolation { + color: #9cdcfe; +} + +.token.punctuation.interpolation-punctuation { + color: #569cd6; +} + +.token.boolean { + color: #569cd6; +} + +.token.property, +.token.variable, +.token.imports .token.maybe-class-name, +.token.exports .token.maybe-class-name { + color: #9cdcfe; +} + +.token.selector { + color: #d7ba7d; +} + +.token.escape { + color: #d7ba7d; +} + +.token.tag { + color: #569cd6; +} + +.token.tag .token.punctuation { + color: #808080; +} + +.token.cdata { + color: #808080; +} + +.token.attr-name { + color: #9cdcfe; +} + +.token.attr-value, +.token.attr-value .token.punctuation { + color: #ce9178; +} + +.token.attr-value .token.punctuation.attr-equals { + color: #d4d4d4; +} + +.token.entity { + color: #569cd6; +} + +.token.namespace { + color: #4ec9b0; +} +/********************************************************* +* Language Specific +*/ + +pre[class*='language-javascript'], +code[class*='language-javascript'], +pre[class*='language-jsx'], +code[class*='language-jsx'], +pre[class*='language-typescript'], +code[class*='language-typescript'], +pre[class*='language-tsx'], +code[class*='language-tsx'] { + color: #9cdcfe; +} + +pre[class*='language-css'], +code[class*='language-css'] { + color: #ce9178; +} + +pre[class*='language-html'], +code[class*='language-html'] { + color: #d4d4d4; +} + +.language-regex .token.anchor { + color: #dcdcaa; +} + +.language-html .token.punctuation { + color: #808080; +} +/********************************************************* +* Line highlighting +*/ +pre[class*='language-'] > code[class*='language-'] { + position: relative; + z-index: 1; +} + +.line-highlight.line-highlight { + background: #f7ebc6; + box-shadow: inset 5px 0 0 #f7d87c; + z-index: 0; +} diff --git a/src/editor/ui/elements/code-block/code-line-element.tsx b/src/editor/ui/elements/code-block/code-line-element.tsx new file mode 100644 index 00000000..52ecca5f --- /dev/null +++ b/src/editor/ui/elements/code-block/code-line-element.tsx @@ -0,0 +1,9 @@ +'use client'; + +import React from 'react'; +import { withRef } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; + +export const CodeLineElement = withRef((props, ref) => ( + +)); diff --git a/src/editor/ui/elements/code-block/code-syntax-leaf.tsx b/src/editor/ui/elements/code-block/code-syntax-leaf.tsx new file mode 100644 index 00000000..ab8e4161 --- /dev/null +++ b/src/editor/ui/elements/code-block/code-syntax-leaf.tsx @@ -0,0 +1,20 @@ +'use client'; + +import React from 'react'; +import { withRef } from '@udecode/cn'; +import { useCodeSyntaxLeaf } from '@udecode/plate-code-block/react'; +import { PlateLeaf } from '@udecode/plate-common/react'; + +export const CodeSyntaxLeaf = withRef( + ({ children, ...props }, ref) => { + const { leaf } = props; + + const { tokenProps } = useCodeSyntaxLeaf({ leaf }); + + return ( + + {children} + + ); + }, +); diff --git a/src/editor/ui/elements/code-block/index.tsx b/src/editor/ui/elements/code-block/index.tsx new file mode 100644 index 00000000..79a5988c --- /dev/null +++ b/src/editor/ui/elements/code-block/index.tsx @@ -0,0 +1,39 @@ +'use client'; + +import React from 'react'; + +import { cn, withRef } from '@udecode/cn'; +import { useCodeBlockElementState } from '@udecode/plate-code-block/react'; +import { PlateElement } from '@udecode/plate-common/react'; + +import { CodeBlockCombobox } from './code-block-combobox'; + +import './code-block.css'; + +export const CodeBlockElement = withRef( + ({ children, className, ...props }, ref) => { + const { element } = props; + const state = useCodeBlockElementState({ element }); + + return ( + +
+          {children}
+        
+ + {state.syntax && ( +
+ +
+ )} +
+ ); + }, +); diff --git a/src/editor/ui/elements/heading.tsx b/src/editor/ui/elements/heading.tsx new file mode 100644 index 00000000..b0e6df77 --- /dev/null +++ b/src/editor/ui/elements/heading.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { withRef, withVariants } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; +import { cva } from 'class-variance-authority'; + +const headingVariants = cva('inline-block', { + variants: { + isFirstBlock: { + false: '', + true: '!mt-0', + }, + variant: { + h1: 'mb-4 mt-[1.4em] text-4xl font-semibold', + h2: 'mb-2 mt-[1.15em] text-3xl font-semibold tracking-tight', + h3: 'mb-1.5 mt-[1.15em] text-2xl font-semibold tracking-tight', + h4: 'mb-1 mt-[1.15em] text-xl font-semibold tracking-tight', + h5: 'mb-1 mt-[1.15em] text-lg font-semibold tracking-tight', + h6: 'mb-1 mt-[1.15em] text-base font-semibold tracking-tight', + }, + }, +}); + +const HeadingElementVariants = withVariants(PlateElement, headingVariants, [ + 'isFirstBlock', + 'variant', +]); + +export const HeadingElement = withRef( + ({ children, isFirstBlock, variant = 'h1', ...props }, ref) => { + const { editor, element } = props; + + const Element = variant ?? 'h1'; + + return ( + + {children} + + ); + }, +); diff --git a/src/editor/ui/elements/hr.tsx b/src/editor/ui/elements/hr.tsx new file mode 100644 index 00000000..80478ac5 --- /dev/null +++ b/src/editor/ui/elements/hr.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { cn, withRef } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; +import { useFocused, useSelected } from 'slate-react'; + +export const HrElement = withRef( + ({ className, nodeProps, children, ...props }, ref) => { + const selected = useSelected(); + const focused = useFocused(); + + return ( + +
+
+
+ {children} +
+ ); + }, +); diff --git a/src/editor/ui/elements/link.tsx b/src/editor/ui/elements/link.tsx new file mode 100644 index 00000000..24cabc0e --- /dev/null +++ b/src/editor/ui/elements/link.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { cn, withRef } from '@udecode/cn'; +import { PlateElement, useElement } from '@udecode/plate-common/react'; +import { useLink } from '@udecode/plate-link/react'; + +import type { TLinkElement } from '@udecode/plate-link'; + +export const LinkElement = withRef( + ({ children, className, ...props }, ref) => { + const element = useElement(); + const { props: linkProps } = useLink({ element }); + + return ( + )} + {...props} + > + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + {children} + + ); + }, +); diff --git a/src/editor/ui/elements/todo-list.tsx b/src/editor/ui/elements/todo-list.tsx new file mode 100644 index 00000000..595a8b6e --- /dev/null +++ b/src/editor/ui/elements/todo-list.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { cn, withRef } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; +import { + useTodoListElement, + useTodoListElementState, +} from '@udecode/plate-list/react'; + +import { Checkbox } from '@/primitives/checkbox'; + +export const TodoListElement = withRef( + ({ children, className, ...props }, ref) => { + const { element } = props; + const state = useTodoListElementState({ element }); + const { checkboxProps } = useTodoListElement(state); + + return ( + +
+ +
+ + {children} + +
+ ); + }, +); diff --git a/src/editor/ui/elements/todo.tsx b/src/editor/ui/elements/todo.tsx new file mode 100644 index 00000000..e4d9f54c --- /dev/null +++ b/src/editor/ui/elements/todo.tsx @@ -0,0 +1,37 @@ +import type { PlateRenderElementProps } from '@udecode/plate-common/react'; + +import { cn } from '@udecode/cn'; +import { + useIndentTodoListElement, + useIndentTodoListElementState, +} from '@udecode/plate-indent-list/react'; + +import { Checkbox } from '@/primitives/checkbox'; + +export const TodoMarker = ({ + element, +}: Omit) => { + const state = useIndentTodoListElementState({ element }); + const { checkboxProps } = useIndentTodoListElement(state); + + return ( +
+ +
+ ); +}; + +export const TodoLi = ({ children, element }: PlateRenderElementProps) => { + return ( + + {children} + + ); +}; diff --git a/src/editor/ui/link-floating-toolbar.tsx b/src/editor/ui/link-floating-toolbar.tsx new file mode 100644 index 00000000..93222257 --- /dev/null +++ b/src/editor/ui/link-floating-toolbar.tsx @@ -0,0 +1,159 @@ +'use client'; + +import React from 'react'; +import { cn } from '@udecode/cn'; +import { useFormInputProps } from '@udecode/plate-common/react'; +import { flip, offset } from '@udecode/plate-floating'; +import { + FloatingLinkUrlInput, + LinkOpenButton, + useFloatingLinkEdit, + useFloatingLinkEditState, + useFloatingLinkInsert, + useFloatingLinkInsertState, +} from '@udecode/plate-link/react'; + +import { buttonVariants } from '@/primitives/button'; +import { inputVariants, Input } from '@/primitives/input'; +import { popoverVariants } from '@/primitives/popover'; +import { Separator } from '@/primitives/separator'; + +import type { UseVirtualFloatingOptions } from '@udecode/plate-floating'; +import type { LinkFloatingToolbarState } from '@udecode/plate-link/react'; +import { ExternalLinkIcon, LinkIcon, TextIcon, UnlinkIcon } from 'lucide-react'; + +const floatingOptions: UseVirtualFloatingOptions = { + middleware: [ + offset(12), + flip({ + fallbackPlacements: ['bottom-end', 'top-start', 'top-end'], + padding: 12, + }), + ], + placement: 'bottom-start', +}; + +export interface LinkFloatingToolbarProps { + state?: LinkFloatingToolbarState; +} + +export function LinkFloatingToolbar({ state }: LinkFloatingToolbarProps) { + 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 = ( +
+
+
+ +
+ + +
+ +
+
+ +
+ +
+
+ ); + + const editContent = editState.isEditing ? ( + input + ) : ( +
+ + + + + + + + + + + +
+ ); + + return ( + <> +
+ {input} +
+ +
+ {editContent} +
+ + ); +} diff --git a/src/editor/ui/placeholder.tsx b/src/editor/ui/placeholder.tsx new file mode 100644 index 00000000..5b010256 --- /dev/null +++ b/src/editor/ui/placeholder.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { cn } from '@udecode/cn'; +import { + createNodeHOC, + createNodesHOC, + ParagraphPlugin, + usePlaceholderState, +} from '@udecode/plate-common/react'; +import { HEADING_KEYS } from '@udecode/plate-heading'; + +import type { + CreateHOCOptions, + NodeComponent, + PlaceholderProps, +} from '@udecode/plate-common/react'; + +interface CustomPlaceholderProps extends PlaceholderProps { + placeholder: string; + nodeProps?: Record; + children: ReactElement; +} + +export const Placeholder = (props: CustomPlaceholderProps) => { + const { children, nodeProps, placeholder } = props; + + const { enabled } = usePlaceholderState(props); + + return React.Children.map(children, (child: ReactElement) => { + return React.cloneElement(child, { + className: (child.props as { className: string }).className, + nodeProps: { + ...nodeProps, + className: cn( + enabled && + 'before:absolute before:cursor-text before:opacity-30 before:content-[attr(placeholder)]', + ), + placeholder, + }, + }); + }); +}; + +export const withPlaceholder = createNodeHOC(Placeholder); + +export const withPlaceholdersPrimitive: ( + components: Record, + options: + | CreateHOCOptions + | CreateHOCOptions[], +) => Record = createNodesHOC(Placeholder); + +export const withPlaceholders: ( + components: Record, +) => Record = (components) => + withPlaceholdersPrimitive(components, [ + { + hideOnBlur: true, + key: ParagraphPlugin.key, + placeholder: 'Type a paragraph', + query: { + maxLevel: 1, + }, + }, + { + hideOnBlur: false, + key: HEADING_KEYS.h1, + placeholder: 'Untitled', + }, + ]); diff --git a/src/editor/ui/with-draggables.tsx b/src/editor/ui/with-draggables.tsx new file mode 100644 index 00000000..c32c875c --- /dev/null +++ b/src/editor/ui/with-draggables.tsx @@ -0,0 +1,108 @@ +import type { FC } from 'react'; + +import { BlockquotePlugin } from '@udecode/plate-block-quote/react'; +import { CodeBlockPlugin } from '@udecode/plate-code-block/react'; +import type { NodeComponent } from '@udecode/plate-common/react'; +import { + ParagraphPlugin, + createNodesWithHOC, +} from '@udecode/plate-common/react'; +import { + type WithDraggableOptions, + withDraggable as withDraggablePrimitive, +} from '@udecode/plate-dnd'; +import { HEADING_KEYS } from '@udecode/plate-heading'; +import { + BulletedListPlugin, + NumberedListPlugin, +} from '@udecode/plate-list/react'; + +import { type DraggableProps, Draggable } from './draggable'; + +interface MyDraggableOptions extends WithDraggableOptions { + keys?: string[]; + key?: string; +} + +export const withDraggable = ( + Component: FC, + options?: MyDraggableOptions>, +) => + withDraggablePrimitive>( + Draggable, + Component, + options, + ); + +export const withDraggablesPrimitive: ( + components: Record, + options: MyDraggableOptions< + Partial> + >[], +) => Record = createNodesWithHOC(withDraggable); + +export const withDraggables = (components: Record) => { + return withDraggablesPrimitive(components, [ + { + keys: [ + ParagraphPlugin.key, + BulletedListPlugin.key, + NumberedListPlugin.key, + ], + level: 0, + draggableProps: { + classNames: { + gutterLeft: 'pt-[1px]', + }, + }, + }, + { + key: HEADING_KEYS.h2, + draggableProps: { + classNames: { + gutterLeft: 'pt-9', + }, + }, + }, + { + key: HEADING_KEYS.h3, + draggableProps: { + classNames: { + gutterLeft: 'pt-7', + }, + }, + }, + { + key: HEADING_KEYS.h4, + draggableProps: { + classNames: { + gutterLeft: 'pt-6', + }, + }, + }, + { + key: HEADING_KEYS.h5, + draggableProps: { + classNames: { + gutterLeft: 'pt-[22px]', + }, + }, + }, + { + key: HEADING_KEYS.h6, + draggableProps: { + classNames: { + gutterLeft: 'pt-[17px]', + }, + }, + }, + { + keys: [BlockquotePlugin.key, CodeBlockPlugin.key], + draggableProps: { + classNames: { + gutterLeft: 'pt-[1px]', + }, + }, + }, + ]); +}; diff --git a/src/primitives/dialog.tsx b/src/primitives/dialog.tsx new file mode 100644 index 00000000..988c9f64 --- /dev/null +++ b/src/primitives/dialog.tsx @@ -0,0 +1,122 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/primitives/icon.tsx b/src/primitives/icon.tsx index 2ebd656f..c100715d 100644 --- a/src/primitives/icon.tsx +++ b/src/primitives/icon.tsx @@ -5,14 +5,26 @@ interface Props { color?: string; size?: number; strokeWidth?: number; + className?: string; } -export const Icon: React.FC = ({ name, color, size, strokeWidth }) => { +export const Icon: React.FC = ({ + name, + color, + size, + strokeWidth, + className, +}) => { // eslint-disable-next-line security/detect-object-injection const LucideIcon = icons[name]; return ( - + ); }; diff --git a/src/primitives/input.tsx b/src/primitives/input.tsx index e9a21f8a..a4dc5183 100644 --- a/src/primitives/input.tsx +++ b/src/primitives/input.tsx @@ -1,24 +1,37 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; export type InputProps = React.InputHTMLAttributes; -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ); +const inputVariants = cva( + 'flex h-9 w-full rounded-md border-gray-subtle-border bg-gray-subtle px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-gray-solid focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + variant: { + outline: + 'border focus-visible:ring-1 focus-visible:ring-pink-solid-hover', + ghost: '', + }, + }, }, ); + +const Input = React.forwardRef< + HTMLInputElement, + InputProps & VariantProps +>(({ className, type, variant = 'outline', ...props }, ref) => { + return ( + + ); +}); Input.displayName = 'Input'; -export { Input }; +export { Input, inputVariants }; diff --git a/src/primitives/popover.tsx b/src/primitives/popover.tsx index add93fdc..bf426782 100644 --- a/src/primitives/popover.tsx +++ b/src/primitives/popover.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import * as PopoverPrimitive from '@radix-ui/react-popover'; import { cn } from '@/lib/utils'; +import { cva } from 'class-variance-authority'; const Popover = PopoverPrimitive.Root; @@ -12,6 +13,10 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; +const popoverVariants = cva( + 'z-50 w-72 rounded-md border bg-gray-subtle text-foreground-muted shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', +); + const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -21,14 +26,17 @@ const PopoverContent = React.forwardRef< ref={ref} align={align} sideOffset={sideOffset} - className={cn( - 'z-50 w-72 rounded-md border bg-gray-subtle text-foreground-muted shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', - className, - )} + className={cn(popoverVariants(), className)} {...props} /> )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + popoverVariants, +}; diff --git a/src/primitives/tooltip.tsx b/src/primitives/tooltip.tsx new file mode 100644 index 00000000..b6e10d57 --- /dev/null +++ b/src/primitives/tooltip.tsx @@ -0,0 +1,38 @@ +'use client'; + +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; + +import { cn } from '@/lib/utils'; + +const TooltipProvider = TooltipPrimitive.Provider; + +const TooltipPortal = TooltipPrimitive.Portal; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + TooltipPortal, +};