| 1 | "use client"; |
| 2 | |
| 3 | import { formatCodeBlock, isLangSupported } from "@platejs/code-block"; |
| 4 | import { BracesIcon, Check, CheckIcon, CopyIcon } from "lucide-react"; |
| 5 | import { NodeApi, type TCodeBlockElement, type TCodeSyntaxLeaf } from "platejs"; |
| 6 | import { |
| 7 | PlateElement, |
| 8 | PlateLeaf, |
| 9 | useEditorRef, |
| 10 | useElement, |
| 11 | useReadOnly, |
| 12 | type PlateElementProps, |
| 13 | type PlateLeafProps, |
| 14 | } from "platejs/react"; |
| 15 | import * as React from "react"; |
| 16 | |
| 17 | import { Button } from "@/components/plate/ui/button"; |
| 18 | import { |
| 19 | Command, |
| 20 | CommandEmpty, |
| 21 | CommandGroup, |
| 22 | CommandInput, |
| 23 | CommandItem, |
| 24 | CommandList, |
| 25 | } from "@/components/plate/ui/command"; |
| 26 | import { |
| 27 | Popover, |
| 28 | PopoverContent, |
| 29 | PopoverTrigger, |
| 30 | } from "@/components/plate/ui/popover"; |
| 31 | import { cn } from "@/lib/utils"; |
| 32 | |
| 33 | export function CodeBlockElement(props: PlateElementProps<TCodeBlockElement>) { |
| 34 | const { editor, element } = props; |
| 35 | |
| 36 | return ( |
| 37 | <PlateElement {...props}> |
| 38 | <style> |
| 39 | {` |
| 40 | .hljs-addition { background: #f0fff4; color: #22863a; } |
| 41 | .hljs-attr, .hljs-attribute, .hljs-literal, .hljs-meta, .hljs-number, .hljs-operator, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id, .hljs-variable { color: #005cc5; } |
| 42 | .hljs-built_in, .hljs-symbol { color: #e36209; } |
| 43 | .hljs-bullet { color: #735c0f; } |
| 44 | .hljs-comment, .hljs-code, .hljs-formula { color: #6a737d; } |
| 45 | .hljs-deletion { background: #ffeef0; color: #b31d28; } |
| 46 | .hljs-emphasis { font-style: italic; } |
| 47 | .hljs-keyword, .hljs-doctag, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable.language_ { color: #d73a49; } |
| 48 | .hljs-name, .hljs-quote, .hljs-selector-tag, .hljs-selector-pseudo { color: #22863a; } |
| 49 | .hljs-regexp, .hljs-string, .hljs-meta_.hljs-string { color: #3593ff; } |
| 50 | .hljs-section { font-weight: bold; color: #005cc5; } |
| 51 | .hljs-strong { font-weight: bold; } |
| 52 | .hljs-title, .hljs-title.class_, .hljs-title.class_.inherited__, .hljs-title.function_ { color: #6f42c1; } |
| 53 | `} |
| 54 | </style> |
| 55 | <div className="relative rounded-md bg-muted/50"> |
| 56 | <pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid"> |
| 57 | <code>{props.children}</code> |
| 58 | </pre> |
| 59 | |
| 60 | <div |
| 61 | className="absolute top-1 right-1 z-10 flex gap-0.5 select-none" |
| 62 | contentEditable={false} |
| 63 | > |
| 64 | {isLangSupported(element.lang) && ( |
| 65 | <Button |
| 66 | size="icon" |
| 67 | variant="ghost" |
| 68 | className="size-6 text-xs" |
| 69 | onClick={() => formatCodeBlock(editor, { element })} |
| 70 | title="Format code" |
| 71 | > |
| 72 | <BracesIcon className="size-3.5! text-muted-foreground" /> |
| 73 | </Button> |
| 74 | )} |
| 75 | |
| 76 | <CodeBlockCombobox /> |
| 77 | |
| 78 | <CopyButton |
| 79 | size="icon" |
| 80 | variant="ghost" |
| 81 | className="size-6 gap-1 text-xs text-muted-foreground" |
| 82 | value={() => NodeApi.string(element)} |
| 83 | /> |
| 84 | </div> |
| 85 | </div> |
| 86 | </PlateElement> |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | function CodeBlockCombobox() { |
| 91 | const [open, setOpen] = React.useState(false); |
| 92 | const commandListId = React.useId(); |
| 93 | const readOnly = useReadOnly(); |
| 94 | const editor = useEditorRef(); |
| 95 | const element = useElement<TCodeBlockElement>(); |
| 96 | const value = element.lang || "plaintext"; |
| 97 | const [searchValue, setSearchValue] = React.useState(""); |
| 98 | |
| 99 | const items = React.useMemo( |
| 100 | () => |
| 101 | languages.filter( |
| 102 | (language) => |
| 103 | !searchValue || |
| 104 | language.label.toLowerCase().includes(searchValue.toLowerCase()), |
| 105 | ), |
| 106 | [searchValue], |
| 107 | ); |
| 108 | |
| 109 | if (readOnly) return null; |
| 110 | |
| 111 | return ( |
| 112 | <Popover open={open} onOpenChange={setOpen}> |
| 113 | <PopoverTrigger asChild> |
| 114 | <Button |
| 115 | size="sm" |
| 116 | variant="ghost" |
| 117 | className="h-6 justify-between gap-1 px-2 text-xs text-muted-foreground select-none" |
| 118 | aria-expanded={open} |
| 119 | aria-controls={commandListId} |
| 120 | role="combobox" |
| 121 | > |
| 122 | {languages.find((language) => language.value === value)?.label ?? |
| 123 | "Plain Text"} |
| 124 | </Button> |
| 125 | </PopoverTrigger> |
| 126 | <PopoverContent |
| 127 | className="w-50 p-0" |
| 128 | onCloseAutoFocus={() => setSearchValue("")} |
| 129 | > |
| 130 | <Command shouldFilter={false}> |
| 131 | <CommandInput |
| 132 | className="h-9" |
| 133 | value={searchValue} |
| 134 | onValueChange={(value) => setSearchValue(value)} |
| 135 | placeholder="Search language..." |
| 136 | /> |
| 137 | <CommandEmpty>No language found.</CommandEmpty> |
| 138 | |
| 139 | <CommandList id={commandListId} className="h-86 overflow-y-auto"> |
| 140 | <CommandGroup> |
| 141 | {items.map((language) => ( |
| 142 | <CommandItem |
| 143 | key={language.label} |
| 144 | className="cursor-pointer" |
| 145 | value={language.value} |
| 146 | onSelect={(value) => { |
| 147 | editor.tf.setNodes( |
| 148 | { lang: value } as Partial<TCodeBlockElement>, |
| 149 | { at: element }, |
| 150 | ); |
| 151 | setSearchValue(value); |
| 152 | setOpen(false); |
| 153 | }} |
| 154 | > |
| 155 | <Check |
| 156 | className={cn( |
| 157 | value === language.value ? "opacity-100" : "opacity-0", |
| 158 | )} |
| 159 | /> |
| 160 | {language.label} |
| 161 | </CommandItem> |
| 162 | ))} |
| 163 | </CommandGroup> |
| 164 | </CommandList> |
| 165 | </Command> |
| 166 | </PopoverContent> |
| 167 | </Popover> |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | function CopyButton({ |
| 172 | value, |
| 173 | ...props |
| 174 | }: { value: (() => string) | string } & Omit< |
| 175 | React.ComponentProps<typeof Button>, |
| 176 | "value" |
| 177 | >) { |
| 178 | const [hasCopied, setHasCopied] = React.useState(false); |
| 179 | |
| 180 | React.useEffect(() => { |
| 181 | if (!hasCopied) { |
| 182 | return; |
| 183 | } |
| 184 | |
| 185 | const timeoutId = window.setTimeout(() => { |
| 186 | setHasCopied(false); |
| 187 | }, 2000); |
| 188 | |
| 189 | return () => { |
| 190 | window.clearTimeout(timeoutId); |
| 191 | }; |
| 192 | }, [hasCopied]); |
| 193 | |
| 194 | return ( |
| 195 | <Button |
| 196 | onClick={() => { |
| 197 | void navigator.clipboard.writeText( |
| 198 | typeof value === "function" ? value() : value, |
| 199 | ); |
| 200 | setHasCopied(true); |
| 201 | }} |
| 202 | {...props} |
| 203 | > |
| 204 | <span className="sr-only">Copy</span> |
| 205 | {hasCopied ? ( |
| 206 | <CheckIcon className="size-3!" /> |
| 207 | ) : ( |
| 208 | <CopyIcon className="size-3!" /> |
| 209 | )} |
| 210 | </Button> |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | export function CodeLineElement(props: PlateElementProps) { |
| 215 | return <PlateElement {...props} />; |
| 216 | } |
| 217 | |
| 218 | export function CodeSyntaxLeaf(props: PlateLeafProps<TCodeSyntaxLeaf>) { |
| 219 | const tokenClassName = props.leaf.className as string; |
| 220 | |
| 221 | return <PlateLeaf className={tokenClassName} {...props} />; |
| 222 | } |
| 223 | |
| 224 | const languages: { label: string; value: string }[] = [ |
| 225 | { label: "Auto", value: "auto" }, |
| 226 | { label: "Plain Text", value: "plaintext" }, |
| 227 | { label: "ABAP", value: "abap" }, |
| 228 | { label: "Agda", value: "agda" }, |
| 229 | { label: "Arduino", value: "arduino" }, |
| 230 | { label: "ASCII Art", value: "ascii" }, |
| 231 | { label: "Assembly", value: "x86asm" }, |
| 232 | { label: "Bash", value: "bash" }, |
| 233 | { label: "BASIC", value: "basic" }, |
| 234 | { label: "BNF", value: "bnf" }, |
| 235 | { label: "C", value: "c" }, |
| 236 | { label: "C#", value: "csharp" }, |
| 237 | { label: "C++", value: "cpp" }, |
| 238 | { label: "Clojure", value: "clojure" }, |
| 239 | { label: "CoffeeScript", value: "coffeescript" }, |
| 240 | { label: "Coq", value: "coq" }, |
| 241 | { label: "CSS", value: "css" }, |
| 242 | { label: "Dart", value: "dart" }, |
| 243 | { label: "Dhall", value: "dhall" }, |
| 244 | { label: "Diff", value: "diff" }, |
| 245 | { label: "Docker", value: "dockerfile" }, |
| 246 | { label: "EBNF", value: "ebnf" }, |
| 247 | { label: "Elixir", value: "elixir" }, |
| 248 | { label: "Elm", value: "elm" }, |
| 249 | { label: "Erlang", value: "erlang" }, |
| 250 | { label: "F#", value: "fsharp" }, |
| 251 | { label: "Flow", value: "flow" }, |
| 252 | { label: "Fortran", value: "fortran" }, |
| 253 | { label: "Gherkin", value: "gherkin" }, |
| 254 | { label: "GLSL", value: "glsl" }, |
| 255 | { label: "Go", value: "go" }, |
| 256 | { label: "GraphQL", value: "graphql" }, |
| 257 | { label: "Groovy", value: "groovy" }, |
| 258 | { label: "Haskell", value: "haskell" }, |
| 259 | { label: "HCL", value: "hcl" }, |
| 260 | { label: "HTML", value: "html" }, |
| 261 | { label: "Idris", value: "idris" }, |
| 262 | { label: "Java", value: "java" }, |
| 263 | { label: "JavaScript", value: "javascript" }, |
| 264 | { label: "JSON", value: "json" }, |
| 265 | { label: "Julia", value: "julia" }, |
| 266 | { label: "Kotlin", value: "kotlin" }, |
| 267 | { label: "LaTeX", value: "latex" }, |
| 268 | { label: "Less", value: "less" }, |
| 269 | { label: "Lisp", value: "lisp" }, |
| 270 | { label: "LiveScript", value: "livescript" }, |
| 271 | { label: "LLVM IR", value: "llvm" }, |
| 272 | { label: "Lua", value: "lua" }, |
| 273 | { label: "Makefile", value: "makefile" }, |
| 274 | { label: "Markdown", value: "markdown" }, |
| 275 | { label: "Markup", value: "markup" }, |
| 276 | { label: "MATLAB", value: "matlab" }, |
| 277 | { label: "Mathematica", value: "mathematica" }, |
| 278 | { label: "Mermaid", value: "mermaid" }, |
| 279 | { label: "Nix", value: "nix" }, |
| 280 | { label: "Notion Formula", value: "notion" }, |
| 281 | { label: "Objective-C", value: "objectivec" }, |
| 282 | { label: "OCaml", value: "ocaml" }, |
| 283 | { label: "Pascal", value: "pascal" }, |
| 284 | { label: "Perl", value: "perl" }, |
| 285 | { label: "PHP", value: "php" }, |
| 286 | { label: "PowerShell", value: "powershell" }, |
| 287 | { label: "Prolog", value: "prolog" }, |
| 288 | { label: "Protocol Buffers", value: "protobuf" }, |
| 289 | { label: "PureScript", value: "purescript" }, |
| 290 | { label: "Python", value: "python" }, |
| 291 | { label: "R", value: "r" }, |
| 292 | { label: "Racket", value: "racket" }, |
| 293 | { label: "Reason", value: "reasonml" }, |
| 294 | { label: "Ruby", value: "ruby" }, |
| 295 | { label: "Rust", value: "rust" }, |
| 296 | { label: "Sass", value: "scss" }, |
| 297 | { label: "Scala", value: "scala" }, |
| 298 | { label: "Scheme", value: "scheme" }, |
| 299 | { label: "SCSS", value: "scss" }, |
| 300 | { label: "Shell", value: "shell" }, |
| 301 | { label: "Smalltalk", value: "smalltalk" }, |
| 302 | { label: "Solidity", value: "solidity" }, |
| 303 | { label: "SQL", value: "sql" }, |
| 304 | { label: "Swift", value: "swift" }, |
| 305 | { label: "TOML", value: "toml" }, |
| 306 | { label: "TypeScript", value: "typescript" }, |
| 307 | { label: "VB.Net", value: "vbnet" }, |
| 308 | { label: "Verilog", value: "verilog" }, |
| 309 | { label: "VHDL", value: "vhdl" }, |
| 310 | { label: "Visual Basic", value: "vbnet" }, |
| 311 | { label: "WebAssembly", value: "wasm" }, |
| 312 | { label: "XML", value: "xml" }, |
| 313 | { label: "YAML", value: "yaml" }, |
| 314 | ]; |
| 315 |