kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 1765 lines 52 kB view raw
1import type { Editor } from "@tiptap/core"; 2import Image from "@tiptap/extension-image"; 3import Link from "@tiptap/extension-link"; 4import Placeholder from "@tiptap/extension-placeholder"; 5import { Table } from "@tiptap/extension-table"; 6import TableCell from "@tiptap/extension-table-cell"; 7import TableHeader from "@tiptap/extension-table-header"; 8import TableRow from "@tiptap/extension-table-row"; 9import TaskList from "@tiptap/extension-task-list"; 10import { Markdown } from "@tiptap/markdown"; 11import { Fragment, Slice } from "@tiptap/pm/model"; 12import { TextSelection } from "@tiptap/pm/state"; 13import { EditorContent, useEditor } from "@tiptap/react"; 14import { BubbleMenu } from "@tiptap/react/menus"; 15import StarterKit from "@tiptap/starter-kit"; 16import { 17 Bold, 18 Braces, 19 Check, 20 ChevronDown, 21 Code, 22 Copy, 23 Heading2, 24 Italic, 25 Link2, 26 List, 27 ListOrdered, 28 ListTodo, 29 Paperclip, 30 Quote, 31 Strikethrough, 32 Table2, 33 Underline as UnderlineIcon, 34} from "lucide-react"; 35import type { MouseEvent as ReactMouseEvent } from "react"; 36import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 37import { bundledLanguages, type Highlighter } from "shiki"; 38import { Button } from "@/components/ui/button"; 39import { Dialog, DialogPopup } from "@/components/ui/dialog"; 40import { Input } from "@/components/ui/input"; 41import { 42 DropdownMenu, 43 DropdownMenuContent, 44 DropdownMenuRadioGroup, 45 DropdownMenuRadioItem, 46 DropdownMenuSeparator, 47 DropdownMenuTrigger, 48} from "@/components/ui/menu"; 49import { useUpdateTaskDescription } from "@/hooks/mutations/task/use-update-task-description"; 50import useGetTask from "@/hooks/queries/task/use-get-task"; 51import { cn } from "@/lib/cn"; 52import debounce from "@/lib/debounce"; 53import { parseTaskListMarkdownToNodes } from "@/lib/editor-task-list-paste"; 54import { 55 extractIssueKeyFromUrl, 56 extractTaskIdFromUrl, 57 isYouTubeUrl, 58 normalizeUrl, 59} from "@/lib/editor-url-utils"; 60import { getSharedShikiHighlighter } from "@/lib/shiki-highlighter"; 61import { toast } from "@/lib/toast"; 62import { uploadTaskImage } from "@/lib/upload-task-image"; 63import { AttachmentCard } from "./extensions/attachment-card"; 64import { EmbedBlock } from "./extensions/embed-block"; 65import { KaneoIssueLink } from "./extensions/kaneo-issue-link"; 66import { 67 SHIKI_CODEBLOCK_REFRESH_META, 68 ShikiCodeBlock, 69} from "./extensions/shiki-code-block"; 70import { TaskItemWithCheckbox } from "./extensions/task-item-with-checkbox"; 71import "tippy.js/dist/tippy.css"; 72 73type TaskDescriptionProps = { 74 taskId: string; 75}; 76 77type HoveredCodeBlock = { 78 language: string; 79 nodePos: number; 80 top: number; 81 left: number; 82}; 83 84type SlashRange = { from: number; to: number }; 85 86type SlashCommand = { 87 id: string; 88 label: string; 89 group: "text" | "lists" | "insert"; 90 shortcut?: string; 91 search: string; 92 run: (editor: Editor, range: SlashRange) => void; 93}; 94 95type SlashMenuState = { 96 from: number; 97 to: number; 98 query: string; 99 top: number; 100 left: number; 101 selectedIndex: number; 102}; 103 104function formatMarkdown(markdown: string) { 105 return markdown 106 .replace(/\r\n/g, "\n") 107 .replace(/\n{3,}/g, "\n\n") 108 .replace(/\n{2,}$/g, "\n"); 109} 110 111type EmbedComposerState = { 112 mode: "choice" | "input"; 113 url: string; 114 top: number; 115 left: number; 116 linkRange?: { from: number; to: number }; 117 range?: SlashRange; 118}; 119 120const CODE_LANGUAGE_OPTIONS = [ 121 { value: "bash", label: "Bash" }, 122 { value: "csharp", label: "C#" }, 123 { value: "cpp", label: "C++" }, 124 { value: "css", label: "CSS" }, 125 { value: "clojure", label: "Clojure" }, 126 { value: "cypher", label: "Cypher" }, 127 { value: "dart", label: "Dart" }, 128 { value: "diff", label: "Diff" }, 129 { value: "elixir", label: "Elixir" }, 130 { value: "excel", label: "Excel" }, 131 { value: "go", label: "Golang" }, 132 { value: "graphql", label: "GraphQL" }, 133 { value: "html", label: "HTML" }, 134 { value: "haskell", label: "Haskell" }, 135 { value: "json", label: "JSON" }, 136 { value: "java", label: "Java" }, 137 { value: "javascript", label: "JavaScript" }, 138 { value: "kotlin", label: "Kotlin" }, 139 { value: "makefile", label: "Makefile" }, 140 { value: "markdown", label: "Markdown" }, 141 { value: "ocaml", label: "OCaml" }, 142 { value: "php", label: "PHP" }, 143 { value: "perl", label: "Perl" }, 144 { value: "plaintext", label: "Plaintext" }, 145 { value: "python", label: "Python" }, 146 { value: "r", label: "R" }, 147 { value: "reasonml", label: "ReasonML" }, 148 { value: "ruby", label: "Ruby" }, 149 { value: "rust", label: "Rust" }, 150 { value: "sql", label: "SQL" }, 151 { value: "swift", label: "Swift" }, 152 { value: "toml", label: "TOML" }, 153 { value: "terraform", label: "Terraform" }, 154 { value: "typescript", label: "TypeScript" }, 155 { value: "xml", label: "XML" }, 156 { value: "yaml", label: "YAML" }, 157]; 158 159const SHIKI_LANGUAGE_ALIASES: Record<string, string> = { 160 excel: "csv", 161 plaintext: "text", 162 reasonml: "ocaml", 163}; 164 165const SLASH_COMMANDS: SlashCommand[] = [ 166 { 167 id: "paragraph", 168 label: "Text", 169 group: "text", 170 search: "text paragraph normal", 171 run: (editor, range) => { 172 editor.chain().focus().deleteRange(range).setParagraph().run(); 173 }, 174 }, 175 { 176 id: "heading-2", 177 label: "Heading", 178 group: "text", 179 shortcut: "Ctrl Alt 2", 180 search: "heading title h2", 181 run: (editor, range) => { 182 editor 183 .chain() 184 .focus() 185 .deleteRange(range) 186 .toggleHeading({ level: 2 }) 187 .run(); 188 }, 189 }, 190 { 191 id: "bullet-list", 192 label: "Bulleted list", 193 group: "lists", 194 shortcut: "Ctrl Alt 8", 195 search: "list bullet unordered", 196 run: (editor, range) => { 197 editor.chain().focus().deleteRange(range).toggleBulletList().run(); 198 }, 199 }, 200 { 201 id: "task-list", 202 label: "To-do list", 203 group: "lists", 204 search: "todo to-do checklist checkbox task list", 205 run: (editor, range) => { 206 editor.chain().focus().deleteRange(range).toggleTaskList().run(); 207 }, 208 }, 209 { 210 id: "ordered-list", 211 label: "Numbered list", 212 group: "lists", 213 shortcut: "Ctrl Alt 9", 214 search: "list ordered numbered", 215 run: (editor, range) => { 216 editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 217 }, 218 }, 219 { 220 id: "blockquote", 221 label: "Quote", 222 group: "insert", 223 search: "quote blockquote", 224 run: (editor, range) => { 225 editor.chain().focus().deleteRange(range).toggleBlockquote().run(); 226 }, 227 }, 228 { 229 id: "code-block", 230 label: "Code block", 231 group: "insert", 232 shortcut: "Ctrl Alt \\", 233 search: "code snippet", 234 run: (editor, range) => { 235 editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); 236 }, 237 }, 238 { 239 id: "table", 240 label: "Table", 241 group: "insert", 242 search: "table grid", 243 run: (editor, range) => { 244 editor 245 .chain() 246 .focus() 247 .deleteRange(range) 248 .insertTable({ cols: 3, rows: 3 }) 249 .run(); 250 }, 251 }, 252]; 253 254export default function TaskDescription({ taskId }: TaskDescriptionProps) { 255 const { data: task } = useGetTask(taskId); 256 const { mutateAsync: updateTaskDescription } = useUpdateTaskDescription(); 257 258 const editorShellRef = useRef<HTMLDivElement | null>(null); 259 const imageInputRef = useRef<HTMLInputElement | null>(null); 260 const dragDepthRef = useRef(0); 261 const taskRef = useRef(task); 262 const updateTaskRef = useRef(updateTaskDescription); 263 const activeTaskIdRef = useRef<string | null>(null); 264 const lastEditorRef = useRef<Editor | null>(null); 265 const pendingImageInsertRef = useRef<{ 266 editor: Editor; 267 range?: SlashRange; 268 } | null>(null); 269 const hasHydratedRef = useRef(false); 270 const isSyncingExternalContentRef = useRef(false); 271 const latestSyncedMarkdownRef = useRef(""); 272 const hoveredCodeBlockElementRef = useRef<HTMLElement | null>(null); 273 const [hoveredCodeBlock, setHoveredCodeBlock] = 274 useState<HoveredCodeBlock | null>(null); 275 const [isCodeLanguageMenuOpen, setIsCodeLanguageMenuOpen] = useState(false); 276 const codeCopyResetTimeoutRef = useRef<number | null>(null); 277 const [isCodeCopied, setIsCodeCopied] = useState(false); 278 const [shikiHighlighter, setShikiHighlighter] = useState<Highlighter | null>( 279 null, 280 ); 281 const shikiHighlighterRef = useRef<Highlighter | null>(null); 282 const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null); 283 const [embedComposer, setEmbedComposer] = useState<EmbedComposerState | null>( 284 null, 285 ); 286 const [embedComposerError, setEmbedComposerError] = useState(""); 287 const [isDragActive, setIsDragActive] = useState(false); 288 const [previewImage, setPreviewImage] = useState<{ 289 src: string; 290 alt: string; 291 } | null>(null); 292 const slashMenuRef = useRef<SlashMenuState | null>(null); 293 294 useEffect(() => { 295 taskRef.current = task; 296 updateTaskRef.current = updateTaskDescription; 297 }, [task, updateTaskDescription]); 298 299 const shikiSupportedLanguages = useMemo( 300 () => new Set([...Object.keys(bundledLanguages), "text"]), 301 [], 302 ); 303 const toShikiLanguage = useCallback( 304 (language: string) => SHIKI_LANGUAGE_ALIASES[language] || language, 305 [], 306 ); 307 const codeLanguages = useMemo( 308 () => 309 CODE_LANGUAGE_OPTIONS.filter(({ value }) => 310 shikiSupportedLanguages.has(toShikiLanguage(value)), 311 ), 312 [shikiSupportedLanguages, toShikiLanguage], 313 ); 314 const getOverlayPosition = useCallback( 315 (editorView: Editor["view"], pos: number) => { 316 const coords = editorView.coordsAtPos(pos); 317 const shellRect = editorShellRef.current?.getBoundingClientRect(); 318 319 if (!shellRect) { 320 return { top: coords.bottom + 8, left: coords.left }; 321 } 322 323 return { 324 top: coords.bottom - shellRect.top + 8, 325 left: coords.left - shellRect.left, 326 }; 327 }, 328 [], 329 ); 330 331 const insertUploadedAsset = useCallback( 332 ( 333 activeEditor: Editor, 334 asset: Awaited<ReturnType<typeof uploadTaskImage>>, 335 range?: SlashRange, 336 ) => { 337 const chain = activeEditor.chain().focus(); 338 339 if (range) { 340 chain.deleteRange(range); 341 } else { 342 const { selection } = activeEditor.state; 343 if (!selection.empty) { 344 chain.setTextSelection(selection.to); 345 } 346 } 347 348 if (asset.kind === "image") { 349 chain 350 .setImage({ 351 src: asset.url, 352 alt: asset.alt, 353 }) 354 .run(); 355 return; 356 } 357 358 chain 359 .insertContent({ 360 type: "attachmentCard", 361 attrs: { 362 url: asset.url, 363 filename: asset.filename, 364 mimeType: asset.mimeType, 365 size: asset.size, 366 }, 367 }) 368 .run(); 369 }, 370 [], 371 ); 372 373 const handleAssetFileUpload = useCallback( 374 async (file: File, targetEditor?: Editor | null, range?: SlashRange) => { 375 const activeEditor = targetEditor || lastEditorRef.current; 376 377 if (!activeEditor) { 378 toast.error("File upload failed"); 379 return; 380 } 381 382 const loadingToast = toast.loading("Uploading file..."); 383 384 try { 385 const uploadedAsset = await uploadTaskImage({ 386 taskId, 387 surface: "description", 388 file, 389 }); 390 insertUploadedAsset(activeEditor, uploadedAsset, range); 391 392 toast.dismiss(loadingToast); 393 toast.success( 394 uploadedAsset.kind === "image" ? "Image uploaded" : "File attached", 395 ); 396 } catch (error) { 397 toast.dismiss(loadingToast); 398 toast.error( 399 error instanceof Error ? error.message : "Failed to upload file", 400 ); 401 } 402 }, 403 [insertUploadedAsset, taskId], 404 ); 405 406 const openImagePicker = useCallback( 407 (activeEditor?: Editor | null, range?: SlashRange) => { 408 pendingImageInsertRef.current = activeEditor 409 ? { editor: activeEditor, range } 410 : null; 411 imageInputRef.current?.click(); 412 }, 413 [], 414 ); 415 416 const hasFileDrag = useCallback((event: React.DragEvent<HTMLElement>) => { 417 return Array.from(event.dataTransfer?.items || []).some( 418 (item) => item.kind === "file", 419 ); 420 }, []); 421 422 const handleShellDragEnter = useCallback( 423 (event: React.DragEvent<HTMLElement>) => { 424 if (!taskId || !hasFileDrag(event)) return; 425 event.preventDefault(); 426 dragDepthRef.current += 1; 427 setIsDragActive(true); 428 }, 429 [hasFileDrag, taskId], 430 ); 431 432 const handleShellDragOver = useCallback( 433 (event: React.DragEvent<HTMLElement>) => { 434 if (!taskId || !hasFileDrag(event)) return; 435 event.preventDefault(); 436 event.dataTransfer.dropEffect = "copy"; 437 if (!isDragActive) { 438 setIsDragActive(true); 439 } 440 }, 441 [hasFileDrag, isDragActive, taskId], 442 ); 443 444 const handleShellDragLeave = useCallback( 445 (event: React.DragEvent<HTMLElement>) => { 446 if (!taskId || !hasFileDrag(event)) return; 447 event.preventDefault(); 448 dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); 449 if (dragDepthRef.current === 0) { 450 setIsDragActive(false); 451 } 452 }, 453 [hasFileDrag, taskId], 454 ); 455 456 const handleShellDrop = useCallback( 457 (event: React.DragEvent<HTMLElement>) => { 458 if (!taskId || !hasFileDrag(event)) return; 459 dragDepthRef.current = 0; 460 setIsDragActive(false); 461 }, 462 [hasFileDrag, taskId], 463 ); 464 465 const slashCommands = useMemo( 466 () => [ 467 ...SLASH_COMMANDS, 468 { 469 id: "file", 470 label: "File", 471 group: "insert" as const, 472 search: "file attachment image photo picture upload", 473 run: (activeEditor: Editor, range: SlashRange) => { 474 activeEditor.chain().focus().deleteRange(range).run(); 475 openImagePicker(activeEditor); 476 }, 477 }, 478 ], 479 [openImagePicker], 480 ); 481 482 useEffect(() => { 483 let isDisposed = false; 484 485 void getSharedShikiHighlighter() 486 .then((nextHighlighter) => { 487 shikiHighlighterRef.current = nextHighlighter; 488 if (!isDisposed) { 489 setShikiHighlighter(nextHighlighter); 490 } 491 }) 492 .catch((error) => { 493 console.error("Failed to initialize Shiki highlighter:", error); 494 }); 495 496 return () => { 497 isDisposed = true; 498 }; 499 }, []); 500 501 const debouncedUpdate = useCallback( 502 debounce(async (markdown: string) => { 503 const currentTask = taskRef.current; 504 const updateTaskFn = updateTaskRef.current; 505 if (!currentTask || !updateTaskFn) return; 506 507 try { 508 await updateTaskFn({ 509 ...currentTask, 510 description: markdown, 511 }); 512 } catch (error) { 513 console.error("Failed to update description:", error); 514 } 515 }, 700), 516 [], 517 ); 518 519 const editor = useEditor( 520 { 521 immediatelyRender: false, 522 extensions: [ 523 StarterKit.configure({ 524 codeBlock: { 525 HTMLAttributes: { class: "kaneo-tiptap-codeblock" }, 526 }, 527 trailingNode: false, 528 heading: { levels: [1, 2, 3] }, 529 }), 530 Link.configure({ 531 autolink: true, 532 defaultProtocol: "https", 533 linkOnPaste: true, 534 openOnClick: false, 535 }), 536 Markdown.configure({ 537 markedOptions: { 538 breaks: true, 539 gfm: true, 540 }, 541 }), 542 ShikiCodeBlock.configure({ 543 highlighter: () => shikiHighlighterRef.current, 544 resolveLanguage: toShikiLanguage, 545 themeDark: "github-dark", 546 themeLight: "github-light", 547 }), 548 EmbedBlock, 549 AttachmentCard, 550 KaneoIssueLink, 551 TaskList, 552 Image.configure({ 553 HTMLAttributes: { 554 class: "kaneo-editor-image", 555 loading: "lazy", 556 }, 557 }), 558 TaskItemWithCheckbox.configure({ 559 nested: true, 560 }), 561 Placeholder.configure({ 562 placeholder: "Write a description…", 563 }), 564 Table.configure({ 565 resizable: true, 566 }), 567 TableRow, 568 TableHeader, 569 TableCell, 570 ], 571 editorProps: { 572 attributes: { 573 class: "kaneo-tiptap-prose", 574 }, 575 handlePaste: (view, event) => { 576 const pastedFiles = Array.from(event.clipboardData?.files || []); 577 const pastedFile = pastedFiles[0]; 578 579 if (pastedFile) { 580 event.preventDefault(); 581 void handleAssetFileUpload(pastedFile, editor); 582 return true; 583 } 584 585 const plainText = event.clipboardData?.getData("text/plain") || ""; 586 const taskListNodes = parseTaskListMarkdownToNodes(plainText); 587 if (taskListNodes) { 588 event.preventDefault(); 589 const nodes = taskListNodes.map((node) => 590 view.state.schema.nodeFromJSON(node), 591 ); 592 const fragment = Fragment.fromArray(nodes); 593 view.dispatch( 594 view.state.tr 595 .replaceSelection(new Slice(fragment, 0, 0)) 596 .scrollIntoView(), 597 ); 598 return true; 599 } 600 601 const pastedText = plainText.trim(); 602 if (!pastedText || /\s/.test(pastedText)) return false; 603 604 const url = normalizeUrl(pastedText); 605 if (!url) return false; 606 607 const issueKey = extractIssueKeyFromUrl(url); 608 const taskIdFromUrl = extractTaskIdFromUrl(url); 609 if (issueKey || taskIdFromUrl) { 610 event.preventDefault(); 611 view.dispatch( 612 view.state.tr.replaceSelectionWith( 613 view.state.schema.nodes.kaneoIssueLink.create({ 614 url, 615 issueKey: issueKey || "", 616 taskId: taskIdFromUrl || "", 617 }), 618 ), 619 ); 620 return true; 621 } 622 623 if (!isYouTubeUrl(url)) return false; 624 625 event.preventDefault(); 626 const { from } = view.state.selection; 627 const linkMark = view.state.schema.marks.link?.create({ href: url }); 628 const linkText = view.state.schema.text( 629 url, 630 linkMark ? [linkMark] : [], 631 ); 632 view.dispatch( 633 view.state.tr 634 .replaceSelectionWith(linkText, false) 635 .scrollIntoView(), 636 ); 637 const coords = getOverlayPosition(view, view.state.selection.from); 638 639 setEmbedComposer({ 640 mode: "choice", 641 url, 642 top: coords.top, 643 left: coords.left, 644 linkRange: { from, to: from + url.length }, 645 }); 646 setEmbedComposerError(""); 647 return true; 648 }, 649 handleDrop: (view, event) => { 650 const droppedFiles = Array.from(event.dataTransfer?.files || []); 651 const droppedFile = droppedFiles[0]; 652 653 if (!droppedFile) return false; 654 655 event.preventDefault(); 656 const coordinates = view.posAtCoords({ 657 left: event.clientX, 658 top: event.clientY, 659 }); 660 const dropRange = coordinates 661 ? { from: coordinates.pos, to: coordinates.pos } 662 : undefined; 663 664 void handleAssetFileUpload(droppedFile, editor, dropRange); 665 return true; 666 }, 667 handleTextInput: (view, _from, _to, text) => { 668 if (text !== "`") return false; 669 670 const { state } = view; 671 const { $from } = state.selection; 672 if ($from.parent.type.name !== "paragraph") return false; 673 674 const textBefore = $from.parent.textBetween( 675 0, 676 $from.parentOffset, 677 "\0", 678 "\0", 679 ); 680 681 if (!/^\s*``$/.test(textBefore)) return false; 682 683 const paragraphStart = $from.before(); 684 const codeBlock = state.schema.nodes.codeBlock?.create(); 685 if (!codeBlock) return false; 686 687 const tr = state.tr.replaceWith( 688 paragraphStart, 689 paragraphStart + $from.parent.nodeSize, 690 codeBlock, 691 ); 692 693 tr.setSelection( 694 TextSelection.near(tr.doc.resolve(paragraphStart + 1)), 695 ); 696 view.dispatch(tr.scrollIntoView()); 697 return true; 698 }, 699 handleKeyDown: (view, event) => { 700 if ( 701 !( 702 (event.metaKey || event.ctrlKey) && 703 event.key.toLowerCase() === "a" 704 ) 705 ) { 706 return false; 707 } 708 709 const { state } = view; 710 const { $from } = state.selection; 711 if ($from.parent.type.name !== "codeBlock") { 712 return false; 713 } 714 715 event.preventDefault(); 716 view.dispatch( 717 state.tr.setSelection( 718 TextSelection.create(state.doc, $from.start(), $from.end()), 719 ), 720 ); 721 return true; 722 }, 723 }, 724 onUpdate: ({ editor: activeEditor }) => { 725 if (isSyncingExternalContentRef.current) return; 726 const markdown = formatMarkdown(activeEditor.getMarkdown()); 727 if (markdown === latestSyncedMarkdownRef.current) return; 728 latestSyncedMarkdownRef.current = markdown; 729 debouncedUpdate(markdown); 730 }, 731 }, 732 [getOverlayPosition, handleAssetFileUpload, toShikiLanguage], 733 ); 734 735 useEffect(() => { 736 if (!editor || !shikiHighlighter) return; 737 editor.view.dispatch( 738 editor.state.tr.setMeta(SHIKI_CODEBLOCK_REFRESH_META, true), 739 ); 740 }, [editor, shikiHighlighter]); 741 742 useEffect(() => { 743 if (!editor || typeof document === "undefined") return; 744 745 const root = document.documentElement; 746 const refreshShikiTheme = () => { 747 editor.view.dispatch( 748 editor.state.tr.setMeta(SHIKI_CODEBLOCK_REFRESH_META, true), 749 ); 750 }; 751 752 const observer = new MutationObserver((mutations) => { 753 for (const mutation of mutations) { 754 if (mutation.attributeName === "class") { 755 refreshShikiTheme(); 756 break; 757 } 758 } 759 }); 760 761 observer.observe(root, { attributes: true, attributeFilter: ["class"] }); 762 return () => { 763 observer.disconnect(); 764 }; 765 }, [editor]); 766 767 useEffect(() => { 768 if (!editor) return; 769 770 const handleImagePreviewClick = (event: MouseEvent) => { 771 const target = event.target as HTMLElement | null; 772 if (!(target instanceof HTMLImageElement)) return; 773 if (!target.classList.contains("kaneo-editor-image")) return; 774 775 event.preventDefault(); 776 setPreviewImage({ 777 src: target.currentSrc || target.src, 778 alt: target.alt || "Preview image", 779 }); 780 }; 781 782 const dom = editor.view.dom; 783 dom.addEventListener("click", handleImagePreviewClick); 784 785 return () => { 786 dom.removeEventListener("click", handleImagePreviewClick); 787 }; 788 }, [editor]); 789 790 useEffect(() => { 791 slashMenuRef.current = slashMenu; 792 }, [slashMenu]); 793 794 const setLink = useCallback( 795 (prefilledUrl?: string) => { 796 if (!editor) return; 797 const previousUrl = editor.getAttributes("link").href as 798 | string 799 | undefined; 800 const url = window.prompt("Enter URL", prefilledUrl || previousUrl || ""); 801 if (url === null) return; 802 if (url.trim() === "") { 803 editor.chain().focus().extendMarkRange("link").unsetLink().run(); 804 return; 805 } 806 editor 807 .chain() 808 .focus() 809 .extendMarkRange("link") 810 .setLink({ href: url }) 811 .run(); 812 }, 813 [editor], 814 ); 815 816 const filteredSlashCommands = useMemo(() => { 817 const query = slashMenu?.query.trim().toLowerCase() || ""; 818 if (!query) return slashCommands; 819 return slashCommands.filter( 820 (command) => 821 command.label.toLowerCase().includes(query) || 822 command.search.includes(query), 823 ); 824 }, [slashCommands, slashMenu?.query]); 825 826 const filteredSlashCommandsRef = useRef<SlashCommand[]>( 827 filteredSlashCommands, 828 ); 829 useEffect(() => { 830 filteredSlashCommandsRef.current = filteredSlashCommands; 831 }, [filteredSlashCommands]); 832 833 const groupedSlashCommands = useMemo( 834 () => [ 835 { 836 title: "Text", 837 items: filteredSlashCommands.filter( 838 (command) => command.group === "text", 839 ), 840 }, 841 { 842 title: "Lists", 843 items: filteredSlashCommands.filter( 844 (command) => command.group === "lists", 845 ), 846 }, 847 { 848 title: "Insert", 849 items: filteredSlashCommands.filter( 850 (command) => command.group === "insert", 851 ), 852 }, 853 ], 854 [filteredSlashCommands], 855 ); 856 857 const runSlashCommand = useCallback( 858 (command: SlashCommand) => { 859 if (!editor || !slashMenuRef.current) return; 860 command.run(editor, { 861 from: slashMenuRef.current.from, 862 to: slashMenuRef.current.to, 863 }); 864 setSlashMenu(null); 865 }, 866 [editor], 867 ); 868 869 const syncSlashMenu = useCallback( 870 (activeEditor: Editor) => { 871 const { state, view } = activeEditor; 872 if (!state.selection.empty) { 873 setSlashMenu(null); 874 return; 875 } 876 877 const { $from } = state.selection; 878 if ($from.parent.type.name === "codeBlock") { 879 setSlashMenu(null); 880 return; 881 } 882 883 const textBeforeCursor = state.doc.textBetween( 884 $from.start(), 885 $from.pos, 886 "\n", 887 "\0", 888 ); 889 const match = /(?:^|\s)\/([^\s/]*)$/.exec(textBeforeCursor); 890 if (!match) { 891 setSlashMenu(null); 892 return; 893 } 894 895 const query = match[1] || ""; 896 const from = $from.pos - query.length - 1; 897 const to = $from.pos; 898 const coords = getOverlayPosition(view, $from.pos); 899 900 setSlashMenu((current) => { 901 const isSameQuery = 902 current?.from === from && 903 current?.to === to && 904 current?.query === query; 905 return { 906 from, 907 to, 908 query, 909 top: coords.top - 2, 910 left: coords.left, 911 selectedIndex: isSameQuery ? current.selectedIndex : 0, 912 }; 913 }); 914 }, 915 [getOverlayPosition], 916 ); 917 918 useEffect(() => { 919 if (!editor) return; 920 if (lastEditorRef.current !== editor) { 921 hasHydratedRef.current = false; 922 lastEditorRef.current = editor; 923 } 924 925 const isTaskChanged = activeTaskIdRef.current !== taskId; 926 if (isTaskChanged) { 927 activeTaskIdRef.current = taskId; 928 hasHydratedRef.current = false; 929 latestSyncedMarkdownRef.current = ""; 930 } 931 932 const incomingMarkdown = formatMarkdown(task?.description || ""); 933 if (!hasHydratedRef.current) { 934 isSyncingExternalContentRef.current = true; 935 latestSyncedMarkdownRef.current = incomingMarkdown; 936 editor.commands.setContent(incomingMarkdown, { 937 emitUpdate: false, 938 contentType: "markdown", 939 }); 940 hasHydratedRef.current = true; 941 requestAnimationFrame(() => { 942 isSyncingExternalContentRef.current = false; 943 }); 944 return; 945 } 946 947 if (editor.isFocused) return; 948 if (incomingMarkdown === latestSyncedMarkdownRef.current) return; 949 950 isSyncingExternalContentRef.current = true; 951 latestSyncedMarkdownRef.current = incomingMarkdown; 952 editor.commands.setContent(incomingMarkdown, { 953 emitUpdate: false, 954 contentType: "markdown", 955 }); 956 requestAnimationFrame(() => { 957 isSyncingExternalContentRef.current = false; 958 }); 959 }, [editor, taskId, task?.description]); 960 961 useEffect(() => { 962 if (!editor) return; 963 964 syncSlashMenu(editor); 965 const onSelection = () => syncSlashMenu(editor); 966 const onUpdate = () => syncSlashMenu(editor); 967 968 editor.on("selectionUpdate", onSelection); 969 editor.on("update", onUpdate); 970 971 return () => { 972 editor.off("selectionUpdate", onSelection); 973 editor.off("update", onUpdate); 974 }; 975 }, [editor, syncSlashMenu]); 976 977 const submitEmbedComposer = useCallback( 978 (mode: "embed" | "link") => { 979 if (!editor || !embedComposer) return; 980 const url = normalizeUrl(embedComposer.url); 981 if (!url) { 982 setEmbedComposerError("Enter a valid URL"); 983 return; 984 } 985 986 const chain = editor.chain().focus(); 987 if (embedComposer.mode === "choice" && mode === "link") { 988 setEmbedComposer(null); 989 setEmbedComposerError(""); 990 return; 991 } 992 993 if (embedComposer.linkRange) { 994 chain.deleteRange(embedComposer.linkRange); 995 } else if (embedComposer.range) { 996 chain.deleteRange(embedComposer.range); 997 } 998 999 if (mode === "link") { 1000 chain 1001 .insertContent({ 1002 type: "text", 1003 text: url, 1004 marks: [ 1005 { 1006 type: "link", 1007 attrs: { 1008 href: url, 1009 }, 1010 }, 1011 ], 1012 }) 1013 .run(); 1014 } else { 1015 if (!isYouTubeUrl(url)) { 1016 setEmbedComposerError("Only YouTube links can be embedded."); 1017 return; 1018 } 1019 chain 1020 .insertContent({ 1021 type: "embedBlock", 1022 attrs: { 1023 url, 1024 mode: "embed", 1025 }, 1026 }) 1027 .run(); 1028 } 1029 1030 setEmbedComposer(null); 1031 setEmbedComposerError(""); 1032 }, 1033 [editor, embedComposer], 1034 ); 1035 1036 useEffect(() => { 1037 const handleKeyDown = (event: KeyboardEvent) => { 1038 if (embedComposer) { 1039 event.stopPropagation(); 1040 event.stopImmediatePropagation(); 1041 1042 if (embedComposer.mode === "choice") { 1043 if (event.key === "ArrowDown" || event.key === "ArrowUp") { 1044 event.preventDefault(); 1045 return; 1046 } 1047 } 1048 1049 if (event.key === "Tab") { 1050 event.preventDefault(); 1051 submitEmbedComposer("embed"); 1052 return; 1053 } 1054 if (event.key === "Enter") { 1055 event.preventDefault(); 1056 submitEmbedComposer( 1057 embedComposer.mode === "choice" ? "embed" : "link", 1058 ); 1059 return; 1060 } 1061 if (event.key === "Escape") { 1062 event.preventDefault(); 1063 setEmbedComposer(null); 1064 setEmbedComposerError(""); 1065 } 1066 return; 1067 } 1068 1069 const current = slashMenuRef.current; 1070 if (!editor || !current || !editor.isFocused) return; 1071 1072 const commands = filteredSlashCommandsRef.current; 1073 if (event.key === "Escape") { 1074 event.preventDefault(); 1075 setSlashMenu(null); 1076 return; 1077 } 1078 1079 if (!commands.length) return; 1080 1081 if (event.key === "ArrowDown") { 1082 event.preventDefault(); 1083 setSlashMenu((value) => 1084 value 1085 ? { 1086 ...value, 1087 selectedIndex: (value.selectedIndex + 1) % commands.length, 1088 } 1089 : value, 1090 ); 1091 return; 1092 } 1093 1094 if (event.key === "ArrowUp") { 1095 event.preventDefault(); 1096 setSlashMenu((value) => 1097 value 1098 ? { 1099 ...value, 1100 selectedIndex: 1101 (value.selectedIndex - 1 + commands.length) % commands.length, 1102 } 1103 : value, 1104 ); 1105 return; 1106 } 1107 1108 if (event.key === "Enter" || event.key === "Tab") { 1109 event.preventDefault(); 1110 const command = commands[current.selectedIndex] || commands[0]; 1111 if (!command) return; 1112 runSlashCommand(command); 1113 } 1114 }; 1115 1116 window.addEventListener("keydown", handleKeyDown, true); 1117 return () => { 1118 window.removeEventListener("keydown", handleKeyDown, true); 1119 }; 1120 }, [editor, embedComposer, runSlashCommand, submitEmbedComposer]); 1121 1122 useEffect(() => { 1123 if (!slashMenu) return; 1124 if (filteredSlashCommands.length === 0) return; 1125 if (slashMenu.selectedIndex < filteredSlashCommands.length) return; 1126 setSlashMenu((value) => (value ? { ...value, selectedIndex: 0 } : value)); 1127 }, [filteredSlashCommands, slashMenu]); 1128 1129 const setCodeLanguage = (language: string | null) => { 1130 if (!editor || !hoveredCodeBlock) return; 1131 const { nodePos } = hoveredCodeBlock; 1132 const resolvedLanguage = language || "auto"; 1133 1134 if (resolvedLanguage === "auto") { 1135 editor 1136 .chain() 1137 .focus() 1138 .setNodeSelection(nodePos) 1139 .updateAttributes("codeBlock", { language: "" }) 1140 .run(); 1141 setHoveredCodeBlock((current) => 1142 current ? { ...current, language: "auto" } : current, 1143 ); 1144 return; 1145 } 1146 1147 editor 1148 .chain() 1149 .focus() 1150 .setNodeSelection(nodePos) 1151 .updateAttributes("codeBlock", { language: resolvedLanguage }) 1152 .run(); 1153 setHoveredCodeBlock((current) => 1154 current ? { ...current, language: resolvedLanguage } : current, 1155 ); 1156 }; 1157 1158 const resolveCodeBlockNodeData = useCallback( 1159 (pos: number) => { 1160 if (!editor) return null; 1161 const resolvedPos = editor.state.doc.resolve( 1162 Math.max(0, Math.min(pos, editor.state.doc.content.size)), 1163 ); 1164 1165 for (let depth = resolvedPos.depth; depth > 0; depth -= 1) { 1166 const node = resolvedPos.node(depth); 1167 if (node.type.name !== "codeBlock") continue; 1168 return { 1169 language: (node.attrs.language as string | undefined) || "auto", 1170 nodePos: resolvedPos.before(depth), 1171 }; 1172 } 1173 1174 return null; 1175 }, 1176 [editor], 1177 ); 1178 1179 const updateHoveredCodeBlockFromElement = useCallback( 1180 (codeBlockElement: HTMLElement | null) => { 1181 if (!editor || !codeBlockElement) { 1182 if (!isCodeLanguageMenuOpen) { 1183 hoveredCodeBlockElementRef.current = null; 1184 setHoveredCodeBlock(null); 1185 } 1186 return; 1187 } 1188 1189 const domPos = editor.view.posAtDOM(codeBlockElement, 0); 1190 const nodeData = resolveCodeBlockNodeData(domPos); 1191 if (!nodeData) return; 1192 1193 const rect = codeBlockElement.getBoundingClientRect(); 1194 const shellRect = editorShellRef.current?.getBoundingClientRect(); 1195 hoveredCodeBlockElementRef.current = codeBlockElement; 1196 setHoveredCodeBlock((current) => { 1197 if (current?.nodePos !== nodeData.nodePos) { 1198 setIsCodeCopied(false); 1199 } 1200 1201 return { 1202 language: nodeData.language, 1203 nodePos: nodeData.nodePos, 1204 top: shellRect ? rect.top - shellRect.top + 8 : rect.top + 8, 1205 left: shellRect ? rect.right - shellRect.left - 10 : rect.right - 10, 1206 }; 1207 }); 1208 }, 1209 [editor, isCodeLanguageMenuOpen, resolveCodeBlockNodeData], 1210 ); 1211 1212 const activeCodeLanguageLabel = 1213 codeLanguages.find( 1214 (language) => language.value === hoveredCodeBlock?.language, 1215 )?.label || "Auto detect"; 1216 1217 useEffect(() => { 1218 return () => { 1219 if (codeCopyResetTimeoutRef.current !== null) { 1220 window.clearTimeout(codeCopyResetTimeoutRef.current); 1221 } 1222 }; 1223 }, []); 1224 1225 const copyHoveredCodeBlock = useCallback(async () => { 1226 if (!editor || !hoveredCodeBlock) return; 1227 const node = editor.state.doc.nodeAt(hoveredCodeBlock.nodePos); 1228 if (!node || node.type.name !== "codeBlock") return; 1229 1230 const content = node.textContent || ""; 1231 if (!content) return; 1232 1233 try { 1234 await navigator.clipboard.writeText(content); 1235 setIsCodeCopied(true); 1236 if (codeCopyResetTimeoutRef.current !== null) { 1237 window.clearTimeout(codeCopyResetTimeoutRef.current); 1238 } 1239 codeCopyResetTimeoutRef.current = window.setTimeout(() => { 1240 setIsCodeCopied(false); 1241 codeCopyResetTimeoutRef.current = null; 1242 }, 1400); 1243 } catch (_error) { 1244 // ignore clipboard write failures 1245 } 1246 }, [editor, hoveredCodeBlock]); 1247 1248 useEffect(() => { 1249 if (!hoveredCodeBlockElementRef.current || !hoveredCodeBlock) return; 1250 const syncPosition = () => { 1251 updateHoveredCodeBlockFromElement(hoveredCodeBlockElementRef.current); 1252 }; 1253 1254 window.addEventListener("scroll", syncPosition, true); 1255 window.addEventListener("resize", syncPosition); 1256 return () => { 1257 window.removeEventListener("scroll", syncPosition, true); 1258 window.removeEventListener("resize", syncPosition); 1259 }; 1260 }, [hoveredCodeBlock, updateHoveredCodeBlockFromElement]); 1261 1262 const handleEditorMouseMove = useCallback( 1263 (event: ReactMouseEvent<HTMLElement>) => { 1264 const target = event.target as HTMLElement; 1265 if (target.closest(".kaneo-codeblock-language")) return; 1266 const hovered = target.closest( 1267 "pre.kaneo-tiptap-codeblock", 1268 ) as HTMLElement | null; 1269 1270 if (!hovered) { 1271 if (!isCodeLanguageMenuOpen) { 1272 hoveredCodeBlockElementRef.current = null; 1273 setHoveredCodeBlock(null); 1274 } 1275 return; 1276 } 1277 1278 updateHoveredCodeBlockFromElement(hovered); 1279 }, 1280 [isCodeLanguageMenuOpen, updateHoveredCodeBlockFromElement], 1281 ); 1282 1283 const handleEditorMouseLeave = useCallback( 1284 (event: ReactMouseEvent<HTMLElement>) => { 1285 const relatedTarget = event.relatedTarget as HTMLElement | null; 1286 if (relatedTarget?.closest(".kaneo-codeblock-language")) return; 1287 if (isCodeLanguageMenuOpen) return; 1288 hoveredCodeBlockElementRef.current = null; 1289 setHoveredCodeBlock(null); 1290 }, 1291 [isCodeLanguageMenuOpen], 1292 ); 1293 1294 return ( 1295 <section 1296 ref={editorShellRef} 1297 aria-label="Task description editor" 1298 className={cn( 1299 "kaneo-tiptap-shell group", 1300 isDragActive && "is-drag-active", 1301 )} 1302 onDragEnter={handleShellDragEnter} 1303 onDragOver={handleShellDragOver} 1304 onDragLeave={handleShellDragLeave} 1305 onDrop={handleShellDrop} 1306 > 1307 <input 1308 ref={imageInputRef} 1309 type="file" 1310 className="sr-only" 1311 onChange={(event) => { 1312 const file = event.target.files?.[0]; 1313 if (!file) return; 1314 1315 const pendingInsert = pendingImageInsertRef.current; 1316 pendingImageInsertRef.current = null; 1317 void handleAssetFileUpload( 1318 file, 1319 pendingInsert?.editor, 1320 pendingInsert?.range, 1321 ); 1322 1323 event.target.value = ""; 1324 }} 1325 /> 1326 {editor && hoveredCodeBlock && ( 1327 <div 1328 className="kaneo-codeblock-language" 1329 style={{ 1330 top: hoveredCodeBlock.top, 1331 left: hoveredCodeBlock.left, 1332 position: "absolute", 1333 }} 1334 > 1335 <button 1336 type="button" 1337 className="kaneo-codeblock-language-trigger kaneo-codeblock-copy-trigger" 1338 aria-label={isCodeCopied ? "Copied" : "Copy code"} 1339 onMouseDown={(event) => { 1340 event.preventDefault(); 1341 }} 1342 onClick={() => { 1343 void copyHoveredCodeBlock(); 1344 }} 1345 > 1346 {isCodeCopied ? ( 1347 <Check className="size-3.5" /> 1348 ) : ( 1349 <Copy className="size-3.5" /> 1350 )} 1351 <span>{isCodeCopied ? "Copied" : "Copy"}</span> 1352 </button> 1353 <DropdownMenu 1354 open={isCodeLanguageMenuOpen} 1355 onOpenChange={setIsCodeLanguageMenuOpen} 1356 > 1357 <DropdownMenuTrigger asChild> 1358 <button 1359 type="button" 1360 className="kaneo-codeblock-language-trigger" 1361 > 1362 <span className="truncate">{activeCodeLanguageLabel}</span> 1363 <ChevronDown className="size-3.5 opacity-70" /> 1364 </button> 1365 </DropdownMenuTrigger> 1366 <DropdownMenuContent 1367 align="end" 1368 side="bottom" 1369 sideOffset={6} 1370 className="max-h-72 w-48 overflow-y-auto" 1371 > 1372 <DropdownMenuRadioGroup 1373 value={hoveredCodeBlock.language} 1374 onValueChange={setCodeLanguage} 1375 > 1376 <DropdownMenuRadioItem value="auto"> 1377 Auto detect 1378 </DropdownMenuRadioItem> 1379 <DropdownMenuSeparator /> 1380 {codeLanguages.map(({ value, label }) => ( 1381 <DropdownMenuRadioItem key={value} value={value}> 1382 {label} 1383 </DropdownMenuRadioItem> 1384 ))} 1385 </DropdownMenuRadioGroup> 1386 </DropdownMenuContent> 1387 </DropdownMenu> 1388 </div> 1389 )} 1390 1391 {editor && ( 1392 <BubbleMenu 1393 editor={editor} 1394 className="kaneo-tiptap-bubble" 1395 shouldShow={({ editor: activeEditor, from, to }) => { 1396 if (activeEditor.isActive("embedBlock")) return false; 1397 if (activeEditor.isActive("image")) return false; 1398 if (activeEditor.isEmpty) return false; 1399 return from !== to; 1400 }} 1401 > 1402 <Button 1403 type="button" 1404 variant="ghost" 1405 size="xs" 1406 className={cn( 1407 "kaneo-tiptap-bubble-btn", 1408 editor.isActive("heading", { level: 2 }) && 1409 "bg-accent text-accent-foreground", 1410 )} 1411 onClick={() => 1412 editor.chain().focus().toggleHeading({ level: 2 }).run() 1413 } 1414 > 1415 <Heading2 className="size-3.5" /> 1416 </Button> 1417 <Button 1418 type="button" 1419 variant="ghost" 1420 size="xs" 1421 className={cn( 1422 "kaneo-tiptap-bubble-btn", 1423 editor.isActive("bulletList") && 1424 "bg-accent text-accent-foreground", 1425 )} 1426 onClick={() => editor.chain().focus().toggleBulletList().run()} 1427 > 1428 <List className="size-3.5" /> 1429 </Button> 1430 <Button 1431 type="button" 1432 variant="ghost" 1433 size="xs" 1434 className={cn( 1435 "kaneo-tiptap-bubble-btn", 1436 editor.isActive("taskList") && "bg-accent text-accent-foreground", 1437 )} 1438 onClick={() => editor.chain().focus().toggleTaskList().run()} 1439 > 1440 <ListTodo className="size-3.5" /> 1441 </Button> 1442 <Button 1443 type="button" 1444 variant="ghost" 1445 size="xs" 1446 className={cn( 1447 "kaneo-tiptap-bubble-btn", 1448 editor.isActive("orderedList") && 1449 "bg-accent text-accent-foreground", 1450 )} 1451 onClick={() => editor.chain().focus().toggleOrderedList().run()} 1452 > 1453 <ListOrdered className="size-3.5" /> 1454 </Button> 1455 <Button 1456 type="button" 1457 variant="ghost" 1458 size="xs" 1459 className={cn( 1460 "kaneo-tiptap-bubble-btn", 1461 editor.isActive("blockquote") && 1462 "bg-accent text-accent-foreground", 1463 )} 1464 onClick={() => editor.chain().focus().toggleBlockquote().run()} 1465 > 1466 <Quote className="size-3.5" /> 1467 </Button> 1468 <Button 1469 type="button" 1470 variant="ghost" 1471 size="xs" 1472 className={cn( 1473 "kaneo-tiptap-bubble-btn", 1474 editor.isActive("codeBlock") && 1475 "bg-accent text-accent-foreground", 1476 )} 1477 onClick={() => editor.chain().focus().toggleCodeBlock().run()} 1478 > 1479 <Braces className="size-3.5" /> 1480 </Button> 1481 <Button 1482 type="button" 1483 variant="ghost" 1484 size="xs" 1485 className="kaneo-tiptap-bubble-btn" 1486 onClick={() => 1487 editor.chain().focus().insertTable({ cols: 3, rows: 3 }).run() 1488 } 1489 > 1490 <Table2 className="size-3.5" /> 1491 </Button> 1492 <span className="kaneo-tiptap-bubble-separator" /> 1493 <Button 1494 type="button" 1495 variant="ghost" 1496 size="xs" 1497 className={cn( 1498 "kaneo-tiptap-bubble-btn", 1499 editor.isActive("bold") && "bg-accent text-accent-foreground", 1500 )} 1501 onClick={() => editor.chain().focus().toggleBold().run()} 1502 > 1503 <Bold className="size-3.5" /> 1504 </Button> 1505 <Button 1506 type="button" 1507 variant="ghost" 1508 size="xs" 1509 className={cn( 1510 "kaneo-tiptap-bubble-btn", 1511 editor.isActive("italic") && "bg-accent text-accent-foreground", 1512 )} 1513 onClick={() => editor.chain().focus().toggleItalic().run()} 1514 > 1515 <Italic className="size-3.5" /> 1516 </Button> 1517 <Button 1518 type="button" 1519 variant="ghost" 1520 size="xs" 1521 className={cn( 1522 "kaneo-tiptap-bubble-btn", 1523 editor.isActive("underline") && 1524 "bg-accent text-accent-foreground", 1525 )} 1526 onClick={() => editor.chain().focus().toggleUnderline().run()} 1527 > 1528 <UnderlineIcon className="size-3.5" /> 1529 </Button> 1530 <Button 1531 type="button" 1532 variant="ghost" 1533 size="xs" 1534 className={cn( 1535 "kaneo-tiptap-bubble-btn", 1536 editor.isActive("strike") && "bg-accent text-accent-foreground", 1537 )} 1538 onClick={() => editor.chain().focus().toggleStrike().run()} 1539 > 1540 <Strikethrough className="size-3.5" /> 1541 </Button> 1542 <Button 1543 type="button" 1544 variant="ghost" 1545 size="xs" 1546 className={cn( 1547 "kaneo-tiptap-bubble-btn", 1548 editor.isActive("code") && "bg-accent text-accent-foreground", 1549 )} 1550 onClick={() => editor.chain().focus().toggleCode().run()} 1551 > 1552 <Code className="size-3.5" /> 1553 </Button> 1554 <Button 1555 type="button" 1556 variant="ghost" 1557 size="xs" 1558 className={cn( 1559 "kaneo-tiptap-bubble-btn", 1560 editor.isActive("link") && "bg-accent text-accent-foreground", 1561 )} 1562 onClick={() => setLink()} 1563 > 1564 <Link2 className="size-3.5" /> 1565 </Button> 1566 </BubbleMenu> 1567 )} 1568 1569 {editor && slashMenu && ( 1570 <div 1571 className="kaneo-tiptap-slash-menu" 1572 style={{ 1573 top: slashMenu.top, 1574 left: slashMenu.left, 1575 position: "absolute", 1576 }} 1577 > 1578 {filteredSlashCommands.length > 0 ? ( 1579 groupedSlashCommands.map((group) => { 1580 if (!group.items.length) return null; 1581 return ( 1582 <div key={group.title} className="kaneo-tiptap-slash-group"> 1583 <div className="kaneo-tiptap-slash-group-title"> 1584 {group.title} 1585 </div> 1586 {group.items.map((command) => { 1587 const index = filteredSlashCommands.findIndex( 1588 (candidate) => candidate.id === command.id, 1589 ); 1590 return ( 1591 <button 1592 key={command.id} 1593 type="button" 1594 className={cn( 1595 "kaneo-tiptap-slash-item", 1596 slashMenu.selectedIndex === index && "is-selected", 1597 )} 1598 onMouseEnter={() => 1599 setSlashMenu((current) => 1600 current 1601 ? { ...current, selectedIndex: index } 1602 : current, 1603 ) 1604 } 1605 onMouseDown={(event) => { 1606 event.preventDefault(); 1607 runSlashCommand(command); 1608 }} 1609 > 1610 <span className="kaneo-tiptap-slash-label"> 1611 {command.label} 1612 </span> 1613 {command.shortcut && ( 1614 <span className="kaneo-tiptap-slash-shortcut"> 1615 {command.shortcut} 1616 </span> 1617 )} 1618 </button> 1619 ); 1620 })} 1621 </div> 1622 ); 1623 }) 1624 ) : ( 1625 <div className="kaneo-tiptap-slash-empty">No commands</div> 1626 )} 1627 </div> 1628 )} 1629 1630 {editor && embedComposer && ( 1631 <div 1632 className="kaneo-embed-composer" 1633 style={{ 1634 top: embedComposer.top, 1635 left: embedComposer.left, 1636 position: "absolute", 1637 }} 1638 > 1639 {embedComposer.mode === "choice" ? ( 1640 <div className="kaneo-embed-choice-menu"> 1641 <button 1642 type="button" 1643 className="kaneo-embed-choice-item is-primary" 1644 onMouseDown={(event) => { 1645 event.preventDefault(); 1646 submitEmbedComposer("embed"); 1647 }} 1648 > 1649 <span>Embed video</span> 1650 <span className="kaneo-embed-choice-hint">Tab</span> 1651 </button> 1652 <button 1653 type="button" 1654 className="kaneo-embed-choice-item" 1655 onMouseDown={(event) => { 1656 event.preventDefault(); 1657 setEmbedComposer(null); 1658 setEmbedComposerError(""); 1659 }} 1660 > 1661 <span>Keep as link</span> 1662 <span className="kaneo-embed-choice-hint">Esc</span> 1663 </button> 1664 </div> 1665 ) : ( 1666 <form 1667 className="kaneo-embed-composer-form" 1668 onSubmit={(event) => { 1669 event.preventDefault(); 1670 submitEmbedComposer("embed"); 1671 }} 1672 > 1673 <Input 1674 size="sm" 1675 value={embedComposer.url} 1676 onChange={(event) => { 1677 setEmbedComposer((current) => 1678 current ? { ...current, url: event.target.value } : current, 1679 ); 1680 if (embedComposerError) setEmbedComposerError(""); 1681 }} 1682 placeholder="Paste URL" 1683 autoFocus 1684 /> 1685 <div className="kaneo-embed-composer-actions"> 1686 <Button 1687 type="button" 1688 size="xs" 1689 variant="ghost" 1690 onClick={() => submitEmbedComposer("link")} 1691 > 1692 As link 1693 </Button> 1694 <Button type="submit" size="xs"> 1695 Embed 1696 </Button> 1697 <Button 1698 type="button" 1699 size="xs" 1700 variant="ghost" 1701 onClick={() => { 1702 setEmbedComposer(null); 1703 setEmbedComposerError(""); 1704 }} 1705 > 1706 Cancel 1707 </Button> 1708 </div> 1709 {embedComposerError && ( 1710 <p className="kaneo-embed-composer-error"> 1711 {embedComposerError} 1712 </p> 1713 )} 1714 </form> 1715 )} 1716 </div> 1717 )} 1718 1719 <EditorContent 1720 editor={editor} 1721 className="kaneo-tiptap-content" 1722 onMouseMove={handleEditorMouseMove} 1723 onMouseLeave={handleEditorMouseLeave} 1724 /> 1725 <button 1726 type="button" 1727 className="kaneo-editor-quick-attach" 1728 onMouseDown={(event) => { 1729 event.preventDefault(); 1730 }} 1731 onClick={() => openImagePicker(editor)} 1732 aria-label="Attach file" 1733 > 1734 <Paperclip className="size-3.5" /> 1735 </button> 1736 {isDragActive && ( 1737 <div className="kaneo-editor-drop-indicator"> 1738 <span>Drop image to upload</span> 1739 </div> 1740 )} 1741 <Dialog 1742 open={Boolean(previewImage)} 1743 onOpenChange={(open) => { 1744 if (!open) setPreviewImage(null); 1745 }} 1746 > 1747 <DialogPopup 1748 className="max-w-6xl border-0 bg-transparent p-0 shadow-none before:hidden" 1749 showCloseButton={false} 1750 bottomStickOnMobile={false} 1751 > 1752 {previewImage && ( 1753 <div className="flex max-h-[90vh] items-center justify-center p-4"> 1754 <img 1755 src={previewImage.src} 1756 alt={previewImage.alt} 1757 className="max-h-[85vh] max-w-[92vw] rounded-xl border border-white/12 bg-black/30 object-contain shadow-2xl" 1758 /> 1759 </div> 1760 )} 1761 </DialogPopup> 1762 </Dialog> 1763 </section> 1764 ); 1765}