kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
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}