diff --git a/packages/editor/package.json b/packages/editor/package.json index 990d306aa..a6a61cffb 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@blueprintjs/popover2": "^2.0.10", + "@radix-ui/react-slot": "^1.0.2", "@tiptap/core": "^2.1.7", "@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-color": "^2.1.11", @@ -48,6 +49,7 @@ "@types/node": "18.15.3", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", + "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "eslint": "8.36.0", "eslint-config-next": "13.2.4", diff --git a/packages/editor/src/ui/editor/index.tsx b/packages/editor/src/ui/editor/index.tsx index 0952869a7..286768f51 100644 --- a/packages/editor/src/ui/editor/index.tsx +++ b/packages/editor/src/ui/editor/index.tsx @@ -11,6 +11,7 @@ import { TiptapEditorProps } from '@/ui/editor/props'; import { UploadImage } from '@/types/upload-image'; import { DeleteImage } from '@/types/delete-image'; import { cn } from '@/lib/utils'; +import { FixedMenu } from './menus/fixed-menu'; interface ITiptapEditor { value: string; @@ -109,11 +110,17 @@ const TiptapEditor = ({ }} className={`tiptap-editor-container cursor-text ${editorClassNames}`} > - {editor && } -
- - - {editor?.isActive("image") && } +
+
+ + + {editor?.isActive("image") && } +
+ {editor && editable !== false && + (
+ +
) + }
); diff --git a/packages/editor/src/ui/editor/menus/fixed-menu/index.tsx b/packages/editor/src/ui/editor/menus/fixed-menu/index.tsx new file mode 100644 index 000000000..31a7ad90d --- /dev/null +++ b/packages/editor/src/ui/editor/menus/fixed-menu/index.tsx @@ -0,0 +1,78 @@ +import { BubbleMenu, BubbleMenuProps } from "@tiptap/react"; +import { FC, useState } from "react"; +import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export interface BubbleMenuItem { + name: string; + isActive: () => boolean; + command: () => void; + icon: typeof BoldIcon; +} + +type EditorBubbleMenuProps = Omit; + +export const FixedMenu: FC = (props: any) => { + const items: BubbleMenuItem[] = [ + { + name: "bold", + isActive: () => props.editor?.isActive("bold"), + command: () => props.editor?.chain().focus().toggleBold().run(), + icon: BoldIcon, + }, + { + name: "italic", + isActive: () => props.editor?.isActive("italic"), + command: () => props.editor?.chain().focus().toggleItalic().run(), + icon: ItalicIcon, + }, + { + name: "underline", + isActive: () => props.editor?.isActive("underline"), + command: () => props.editor?.chain().focus().toggleUnderline().run(), + icon: UnderlineIcon, + }, + { + name: "strike", + isActive: () => props.editor?.isActive("strike"), + command: () => props.editor?.chain().focus().toggleStrike().run(), + icon: StrikethroughIcon, + }, + { + name: "code", + isActive: () => props.editor?.isActive("code"), + command: () => props.editor?.chain().focus().toggleCode().run(), + icon: CodeIcon, + }, + ]; + + + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+
+ ); +}; diff --git a/packages/editor/src/ui/light-editor/extensions/image/image-resize.tsx b/packages/editor/src/ui/light-editor/extensions/image/image-resize.tsx new file mode 100644 index 000000000..448b8811c --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/image/image-resize.tsx @@ -0,0 +1,44 @@ +import { Editor } from "@tiptap/react"; +import Moveable from "react-moveable"; + +export const ImageResizer = ({ editor }: { editor: Editor }) => { + const updateMediaSize = () => { + const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; + if (imageInfo) { + const selection = editor.state.selection; + editor.commands.setImage({ + src: imageInfo.src, + width: Number(imageInfo.style.width.replace("px", "")), + height: Number(imageInfo.style.height.replace("px", "")), + } as any); + editor.commands.setNodeSelection(selection.from); + } + }; + + return ( + <> + { + delta[0] && (target!.style.width = `${width}px`); + delta[1] && (target!.style.height = `${height}px`); + }} + onResizeEnd={() => { + updateMediaSize(); + }} + scalable={true} + renderDirections={["w", "e"]} + onScale={({ target, transform }: any) => { + target!.style.transform = transform; + }} + /> + + ); +}; diff --git a/packages/editor/src/ui/light-editor/extensions/image/updated-image.tsx b/packages/editor/src/ui/light-editor/extensions/image/updated-image.tsx new file mode 100644 index 000000000..2ba977f57 --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/image/updated-image.tsx @@ -0,0 +1,23 @@ +import Image from "@tiptap/extension-image"; +import TrackImageDeletionPlugin from "@/ui/editor/plugins/delete-image"; +import UploadImagesPlugin from "@/ui/editor/plugins/upload-image"; +import { DeleteImage } from "@/types/delete-image"; + +const UpdatedImage = (deleteImage: DeleteImage) => Image.extend({ + addProseMirrorPlugins() { + return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)]; + }, + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, +}); + +export default UpdatedImage; diff --git a/packages/editor/src/ui/light-editor/extensions/index.tsx b/packages/editor/src/ui/light-editor/extensions/index.tsx new file mode 100644 index 000000000..5fd7f9ad1 --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/index.tsx @@ -0,0 +1,155 @@ +import StarterKit from "@tiptap/starter-kit"; +import HorizontalRule from "@tiptap/extension-horizontal-rule"; +import TiptapLink from "@tiptap/extension-link"; +import Placeholder from "@tiptap/extension-placeholder"; +import TiptapUnderline from "@tiptap/extension-underline"; +import TextStyle from "@tiptap/extension-text-style"; +import { Color } from "@tiptap/extension-color"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import { Markdown } from "tiptap-markdown"; +import Highlight from "@tiptap/extension-highlight"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { InputRule } from "@tiptap/core"; +import Gapcursor from "@tiptap/extension-gapcursor"; +import { Table } from "@/ui/editor/extensions/table/table"; +import { TableHeader } from "@/ui/editor/extensions/table/table-header"; +import { TableRow } from "@tiptap/extension-table-row"; +import { CustomTableCell } from "@/ui/editor/extensions/table/table-cell"; + +import UpdatedImage from "@/ui/editor/extensions/image/updated-image"; +import SlashCommand from "@/ui/editor/extensions/slash-command"; + +import { DeleteImage } from "@/types/delete-image"; +import { UploadImage } from "@/types/upload-image"; + +import isValidHttpUrl from "@/ui/editor/menus/bubble-menu/utils" + +import ts from "highlight.js/lib/languages/typescript"; +import { lowlight } from "lowlight/lib/core"; +import "highlight.js/styles/github-dark.css"; + +lowlight.registerLanguage("ts", ts); + +export const TiptapExtensions = ( + workspaceSlug: string, + uploadFile: UploadImage, + deleteFile: DeleteImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +) => [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside leading-3 -mt-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside leading-3 -mt-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "leading-normal -mb-2", + }, + }, + blockquote: { + HTMLAttributes: { + class: "border-l-4 border-custom-border-300", + }, + }, + code: { + HTMLAttributes: { + class: + "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", + spellcheck: "false", + }, + }, + codeBlock: false, + horizontalRule: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 2, + }, + gapcursor: false, + }), + CodeBlockLowlight.configure({ + lowlight, + }), + HorizontalRule.extend({ + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range, commands }) => { + commands.splitBlock(); + + const attributes = {}; + const { tr } = state; + const start = range.from; + const end = range.to; + // @ts-ignore + tr.replaceWith(start - 1, end, this.type.create(attributes)); + }, + }), + ]; + }, + }).configure({ + HTMLAttributes: { + class: "mb-6 border-t border-custom-border-300", + }, + }), + Gapcursor, + TiptapLink.configure({ + protocols: ["http", "https"], + validate: (url) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + UpdatedImage(deleteFile).configure({ + HTMLAttributes: { + class: "rounded-lg border border-custom-border-300", + }, + }), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + if (node.type.name === "image" || node.type.name === "table") { + return ""; + } + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + SlashCommand(workspaceSlug, uploadFile, setIsSubmitting), + TiptapUnderline, + TextStyle, + Color, + Highlight.configure({ + multicolor: true, + }), + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + CustomTableCell, + TableRow, + ]; diff --git a/packages/editor/src/ui/light-editor/extensions/slash-command.tsx b/packages/editor/src/ui/light-editor/extensions/slash-command.tsx new file mode 100644 index 000000000..4fba7af5f --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/slash-command.tsx @@ -0,0 +1,365 @@ +import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; +import { Editor, Range, Extension } from "@tiptap/core"; +import Suggestion from "@tiptap/suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import tippy from "tippy.js"; +import { + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Text, + TextQuote, + Code, + MinusSquare, + CheckSquare, + ImageIcon, + Table, +} from "lucide-react"; +import { startImageUpload } from "@/ui/editor/plugins/upload-image"; +import { cn } from "@/lib/utils"; +import { UploadImage } from "@/types/upload-image"; + +interface CommandItemProps { + title: string; + description: string; + icon: ReactNode; +} + +interface CommandProps { + editor: Editor; + range: Range; +} + +const Command = Extension.create({ + name: "slash-command", + addOptions() { + return { + suggestion: { + char: "/", + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + props.command({ editor, range }); + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + allow({ editor }) { + return !editor.isActive("table"); + }, + ...this.options.suggestion, + }), + ]; + }, +}); + +const getSuggestionItems = + ( + workspaceSlug: string, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + ) => + ({ query }: { query: string }) => + [ + { + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); + }, + }, + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + }, + }, + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + }, + }, + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + }, + }, + { + title: "To-do List", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }: CommandProps) => { + // @ts-ignore + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + title: "Divider", + description: "Visually divide blocks", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + }, + }, + { + title: "Table", + description: "Create a Table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + }, + }, + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }: CommandProps) => { + // @ts-ignore + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }: CommandProps) => + // @ts-ignore + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(), + }, + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: "Image", + description: "Upload an image from your computer.", + searchTerms: ["photo", "picture", "media"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).run(); + // upload image + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + if (input.files?.length) { + const file = input.files[0]; + const pos = editor.view.state.selection.from; + startImageUpload(file, editor.view, pos, workspaceSlug, uploadFile, setIsSubmitting); + } + }; + input.click(); + }, + }, + ].filter((item) => { + if (typeof query === "string" && query.length > 0) { + const search = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(search) || + item.description.toLowerCase().includes(search) || + (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) + ); + } + return true; + }); + +export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { + const containerHeight = container.offsetHeight; + const itemHeight = item ? item.offsetHeight : 0; + + const top = item.offsetTop; + const bottom = top + itemHeight; + + if (top < container.scrollTop) { + container.scrollTop -= container.scrollTop - top + 5; + } else if (bottom > containerHeight + container.scrollTop) { + container.scrollTop += bottom - containerHeight - container.scrollTop + 5; + } +}; + +const CommandList = ({ + items, + command, +}: { + items: CommandItemProps[]; + command: any; + editor: any; + range: any; +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = useCallback( + (index: number) => { + const item = items[index]; + if (item) { + command(item); + } + }, + [command, items] + ); + + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + if (e.key === "ArrowUp") { + setSelectedIndex((selectedIndex + items.length - 1) % items.length); + return true; + } + if (e.key === "ArrowDown") { + setSelectedIndex((selectedIndex + 1) % items.length); + return true; + } + if (e.key === "Enter") { + selectItem(selectedIndex); + return true; + } + return false; + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [items, selectedIndex, setSelectedIndex, selectItem]); + + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + const commandListContainer = useRef(null); + + useLayoutEffect(() => { + const container = commandListContainer?.current; + + const item = container?.children[selectedIndex] as HTMLElement; + + if (item && container) updateScrollView(container, item); + }, [selectedIndex]); + + return items.length > 0 ? ( +
+ {items.map((item: CommandItemProps, index: number) => ( + + ))} +
+ ) : null; +}; + +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + component = new ReactRenderer(CommandList, { + props, + editor: props.editor, + }); + + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector("#tiptap-container"), + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + // @ts-ignore + return component?.ref?.onKeyDown(props); + }, + onExit: () => { + popup?.[0].destroy(); + component?.destroy(); + }, + }; +}; + +export const SlashCommand = ( + workspaceSlug: string, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +) => + Command.configure({ + suggestion: { + items: getSuggestionItems(workspaceSlug, uploadFile, setIsSubmitting), + render: renderItems, + }, + }); + +export default SlashCommand; diff --git a/packages/editor/src/ui/light-editor/extensions/table/table-cell.ts b/packages/editor/src/ui/light-editor/extensions/table/table-cell.ts new file mode 100644 index 000000000..643cb8c64 --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/table/table-cell.ts @@ -0,0 +1,32 @@ +import { TableCell } from "@tiptap/extension-table-cell"; + +export const CustomTableCell = TableCell.extend({ + addAttributes() { + return { + ...this.parent?.(), + isHeader: { + default: false, + parseHTML: (element) => { + isHeader: element.tagName === "TD"; + }, + renderHTML: (attributes) => { + tag: attributes.isHeader ? "th" : "td"; + }, + }, + }; + }, + renderHTML({ HTMLAttributes }) { + if (HTMLAttributes.isHeader) { + return [ + "th", + { + ...HTMLAttributes, + class: `relative ${HTMLAttributes.class}`, + }, + ["span", { class: "absolute top-0 right-0" }], + 0, + ]; + } + return ["td", HTMLAttributes, 0]; + }, +}); diff --git a/packages/editor/src/ui/light-editor/extensions/table/table-header.ts b/packages/editor/src/ui/light-editor/extensions/table/table-header.ts new file mode 100644 index 000000000..f23aa93ef --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/table/table-header.ts @@ -0,0 +1,7 @@ +import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header"; + +const TableHeader = BaseTableHeader.extend({ + content: "paragraph", +}); + +export { TableHeader }; diff --git a/packages/editor/src/ui/light-editor/extensions/table/table.ts b/packages/editor/src/ui/light-editor/extensions/table/table.ts new file mode 100644 index 000000000..9b727bb51 --- /dev/null +++ b/packages/editor/src/ui/light-editor/extensions/table/table.ts @@ -0,0 +1,9 @@ +import { Table as BaseTable } from "@tiptap/extension-table"; + +const Table = BaseTable.configure({ + resizable: true, + cellMinWidth: 100, + allowTableNodeSelection: true, +}); + +export { Table }; diff --git a/packages/editor/src/ui/light-editor/index.tsx b/packages/editor/src/ui/light-editor/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/editor/src/ui/light-editor/menus/bubble-menu/index.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/index.tsx new file mode 100644 index 000000000..9592cf617 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/index.tsx @@ -0,0 +1,121 @@ +import { BubbleMenu, BubbleMenuProps } from "@tiptap/react"; +import { FC, useState } from "react"; +import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react"; + +import { NodeSelector } from "./node-selector"; +import { LinkSelector } from "./link-selector"; +import { cn } from "@/lib/utils"; + +export interface BubbleMenuItem { + name: string; + isActive: () => boolean; + command: () => void; + icon: typeof BoldIcon; +} + +type EditorBubbleMenuProps = Omit; + +export const EditorBubbleMenu: FC = (props: any) => { + const items: BubbleMenuItem[] = [ + { + name: "bold", + isActive: () => props.editor?.isActive("bold"), + command: () => props.editor?.chain().focus().toggleBold().run(), + icon: BoldIcon, + }, + { + name: "italic", + isActive: () => props.editor?.isActive("italic"), + command: () => props.editor?.chain().focus().toggleItalic().run(), + icon: ItalicIcon, + }, + { + name: "underline", + isActive: () => props.editor?.isActive("underline"), + command: () => props.editor?.chain().focus().toggleUnderline().run(), + icon: UnderlineIcon, + }, + { + name: "strike", + isActive: () => props.editor?.isActive("strike"), + command: () => props.editor?.chain().focus().toggleStrike().run(), + icon: StrikethroughIcon, + }, + { + name: "code", + isActive: () => props.editor?.isActive("code"), + command: () => props.editor?.chain().focus().toggleCode().run(), + icon: CodeIcon, + }, + ]; + + const bubbleMenuProps: EditorBubbleMenuProps = { + ...props, + shouldShow: ({ editor }) => { + if (!editor.isEditable) { + return false; + } + if (editor.isActive("image")) { + return false; + } + return editor.view.state.selection.content().size > 0; + }, + tippyOptions: { + moveTransition: "transform 0.15s ease-out", + onHidden: () => { + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }, + }, + }; + + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); + + return ( + + {!props.editor.isActive("table") && ( + { + setIsNodeSelectorOpen(!isNodeSelectorOpen); + setIsLinkSelectorOpen(false); + }} + /> + )} + { + setIsLinkSelectorOpen(!isLinkSelectorOpen); + setIsNodeSelectorOpen(false); + }} + /> +
+ {items.map((item, index) => ( + + ))} +
+
+ ); +}; diff --git a/packages/editor/src/ui/light-editor/menus/bubble-menu/link-selector.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/link-selector.tsx new file mode 100644 index 000000000..4f8284506 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/link-selector.tsx @@ -0,0 +1,93 @@ +import { cn } from "@/lib/utils"; +import { Editor } from "@tiptap/core"; +import { Check, Trash } from "lucide-react"; +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; +import isValidHttpUrl from "@/ui/editor/menus/bubble-menu/utils"; + +interface LinkSelectorProps { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export const LinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { + const inputRef = useRef(null); + + const onLinkSubmit = useCallback(() => { + const input = inputRef.current; + const url = input?.value; + if (url && isValidHttpUrl(url)) { + editor.chain().focus().setLink({ href: url }).run(); + setIsOpen(false); + } + }, [editor, inputRef, setIsOpen]); + + useEffect(() => { + inputRef.current && inputRef.current?.focus(); + }); + + return ( +
+ + {isOpen && ( +
{ + if (e.key === "Enter") { + e.preventDefault(); + onLinkSubmit(); + } + }} + > + + {editor.getAttributes("link").href ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; diff --git a/packages/editor/src/ui/light-editor/menus/bubble-menu/node-selector.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/node-selector.tsx new file mode 100644 index 000000000..999184506 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/node-selector.tsx @@ -0,0 +1,130 @@ +import { cn } from "@/lib/utils"; +import { Editor } from "@tiptap/core"; +import { + Check, + ChevronDown, + Heading1, + Heading2, + Heading3, + TextQuote, + ListOrdered, + TextIcon, + Code, + CheckSquare, +} from "lucide-react"; +import { Dispatch, FC, SetStateAction } from "react"; + +import { BubbleMenuItem } from "."; + +interface NodeSelectorProps { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export const NodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { + const items: BubbleMenuItem[] = [ + { + name: "Text", + icon: TextIcon, + command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(), + isActive: () => + editor.isActive("paragraph") && + !editor.isActive("bulletList") && + !editor.isActive("orderedList"), + }, + { + name: "H1", + icon: Heading1, + command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive("heading", { level: 1 }), + }, + { + name: "H2", + icon: Heading2, + command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + isActive: () => editor.isActive("heading", { level: 2 }), + }, + { + name: "H3", + icon: Heading3, + command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), + isActive: () => editor.isActive("heading", { level: 3 }), + }, + { + name: "To-do List", + icon: CheckSquare, + command: () => editor.chain().focus().toggleTaskList().run(), + isActive: () => editor.isActive("taskItem"), + }, + { + name: "Bullet List", + icon: ListOrdered, + command: () => editor.chain().focus().toggleBulletList().run(), + isActive: () => editor.isActive("bulletList"), + }, + { + name: "Numbered List", + icon: ListOrdered, + command: () => editor.chain().focus().toggleOrderedList().run(), + isActive: () => editor.isActive("orderedList"), + }, + { + name: "Quote", + icon: TextQuote, + command: () => + editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(), + isActive: () => editor.isActive("blockquote"), + }, + { + name: "Code", + icon: Code, + command: () => editor.chain().focus().toggleCodeBlock().run(), + isActive: () => editor.isActive("codeBlock"), + }, + ]; + + const activeItem = items.filter((item) => item.isActive()).pop() ?? { + name: "Multiple", + }; + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/packages/editor/src/ui/light-editor/menus/bubble-menu/utils/index.tsx b/packages/editor/src/ui/light-editor/menus/bubble-menu/utils/index.tsx new file mode 100644 index 000000000..b5add3f54 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/bubble-menu/utils/index.tsx @@ -0,0 +1,11 @@ +export default function isValidHttpUrl(string: string): boolean { + let url: URL; + + try { + url = new URL(string); + } catch (_) { + return false; + } + + return url.protocol === "http:" || url.protocol === "https:"; +} diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertBottomTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertBottomTableIcon.tsx new file mode 100644 index 000000000..0e42ba648 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertBottomTableIcon.tsx @@ -0,0 +1,16 @@ +const InsertBottomTableIcon = (props: any) => ( + + + +); + +export default InsertBottomTableIcon; diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertLeftTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertLeftTableIcon.tsx new file mode 100644 index 000000000..1fd75fe87 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertLeftTableIcon.tsx @@ -0,0 +1,15 @@ +const InsertLeftTableIcon = (props: any) => ( + + + +); +export default InsertLeftTableIcon; diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertRightTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertRightTableIcon.tsx new file mode 100644 index 000000000..1a6570969 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertRightTableIcon.tsx @@ -0,0 +1,16 @@ +const InsertRightTableIcon = (props: any) => ( + + + +); + +export default InsertRightTableIcon; diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/InsertTopTableIcon.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/InsertTopTableIcon.tsx new file mode 100644 index 000000000..8f04f4f61 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/table-menu/InsertTopTableIcon.tsx @@ -0,0 +1,15 @@ +const InsertTopTableIcon = (props: any) => ( + + + +); +export default InsertTopTableIcon; diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/index.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/index.tsx new file mode 100644 index 000000000..4b342e6e6 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/table-menu/index.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from "react"; +import { Rows, Columns, ToggleRight } from "lucide-react"; +import InsertLeftTableIcon from "./InsertLeftTableIcon"; +import InsertRightTableIcon from "./InsertRightTableIcon"; +import InsertTopTableIcon from "./InsertTopTableIcon"; +import InsertBottomTableIcon from "./InsertBottomTableIcon"; +import { cn } from "@/lib/utils"; +import { Tooltip } from "./tooltip"; + +interface TableMenuItem { + command: () => void; + icon: any; + key: string; + name: string; +} + +export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { + while (node !== null && node.nodeName !== "TABLE") { + node = node.parentNode; + } + return node as HTMLTableElement; +}; + +export const TableMenu = ({ editor }: { editor: any }) => { + const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); + const isOpen = editor?.isActive("table"); + + const items: TableMenuItem[] = [ + { + command: () => editor.chain().focus().addColumnBefore().run(), + icon: InsertLeftTableIcon, + key: "insert-column-left", + name: "Insert 1 column left", + }, + { + command: () => editor.chain().focus().addColumnAfter().run(), + icon: InsertRightTableIcon, + key: "insert-column-right", + name: "Insert 1 column right", + }, + { + command: () => editor.chain().focus().addRowBefore().run(), + icon: InsertTopTableIcon, + key: "insert-row-above", + name: "Insert 1 row above", + }, + { + command: () => editor.chain().focus().addRowAfter().run(), + icon: InsertBottomTableIcon, + key: "insert-row-below", + name: "Insert 1 row below", + }, + { + command: () => editor.chain().focus().deleteColumn().run(), + icon: Columns, + key: "delete-column", + name: "Delete column", + }, + { + command: () => editor.chain().focus().deleteRow().run(), + icon: Rows, + key: "delete-row", + name: "Delete row", + }, + { + command: () => editor.chain().focus().toggleHeaderRow().run(), + icon: ToggleRight, + key: "toggle-header-row", + name: "Toggle header row", + }, + ]; + + useEffect(() => { + if (!window) return; + + const handleWindowClick = () => { + const selection: any = window?.getSelection(); + + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + const tableNode = findTableAncestor(range.startContainer); + + let parent = tableNode?.parentElement; + + if (tableNode) { + const tableRect = tableNode.getBoundingClientRect(); + const tableCenter = tableRect.left + tableRect.width / 2; + const menuWidth = 45; + const menuLeft = tableCenter - menuWidth / 2; + const tableBottom = tableRect.bottom; + + setTableLocation({ bottom: tableBottom, left: menuLeft }); + + while (parent) { + if (!parent.classList.contains("disable-scroll")) + parent.classList.add("disable-scroll"); + parent = parent.parentElement; + } + } else { + const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); + + scrollDisabledContainers.forEach((container) => { + container.classList.remove("disable-scroll"); + }); + } + } + }; + + window.addEventListener("click", handleWindowClick); + + return () => { + window.removeEventListener("click", handleWindowClick); + }; + }, [tableLocation, editor]); + + return ( +
+ {items.map((item, index) => ( + + + + ))} +
+ ); +}; diff --git a/packages/editor/src/ui/light-editor/menus/table-menu/tooltip.tsx b/packages/editor/src/ui/light-editor/menus/table-menu/tooltip.tsx new file mode 100644 index 000000000..f29d8a491 --- /dev/null +++ b/packages/editor/src/ui/light-editor/menus/table-menu/tooltip.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +// next-themes +import { useTheme } from "next-themes"; +// tooltip2 +import { Tooltip2 } from "@blueprintjs/popover2"; + +type Props = { + tooltipHeading?: string; + tooltipContent: string | React.ReactNode; + position?: + | "top" + | "right" + | "bottom" + | "left" + | "auto" + | "auto-end" + | "auto-start" + | "bottom-left" + | "bottom-right" + | "left-bottom" + | "left-top" + | "right-bottom" + | "right-top" + | "top-left" + | "top-right"; + children: JSX.Element; + disabled?: boolean; + className?: string; + openDelay?: number; + closeDelay?: number; +}; + +export const Tooltip: React.FC = ({ + tooltipHeading, + tooltipContent, + position = "top", + children, + disabled = false, + className = "", + openDelay = 200, + closeDelay, +}) => { + const { theme } = useTheme(); + + return ( + + {tooltipHeading && ( +
+ {tooltipHeading} +
+ )} + {tooltipContent} + + } + position={position} + renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => + React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props }) + } + /> + ); +}; diff --git a/packages/editor/src/ui/light-editor/plugins/delete-image.tsx b/packages/editor/src/ui/light-editor/plugins/delete-image.tsx new file mode 100644 index 000000000..9204481a8 --- /dev/null +++ b/packages/editor/src/ui/light-editor/plugins/delete-image.tsx @@ -0,0 +1,68 @@ +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { DeleteImage } from "@/types/delete-image"; + +const deleteKey = new PluginKey("delete-image"); +const IMAGE_NODE_TYPE = "image"; + +interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => + new Plugin({ + key: deleteKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + oldState.doc.descendants((oldNode, oldPos) => { + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + if (oldPos < 0 || oldPos > newState.doc.content.size) return; + if (!newState.doc.resolve(oldPos).parent) return; + + const newNode = newState.doc.nodeAt(oldPos); + + // Check if the node has been deleted or replaced + if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) { + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }); + +export default TrackImageDeletionPlugin; + +async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + const resStatus = await deleteImage(assetUrlWithWorkspaceId); + if (resStatus === 204) { + console.log("Image deleted successfully"); + } + } catch (error) { + console.error("Error deleting image: ", error); + } +} diff --git a/packages/editor/src/ui/light-editor/plugins/upload-image.tsx b/packages/editor/src/ui/light-editor/plugins/upload-image.tsx new file mode 100644 index 000000000..4c3bbf9a8 --- /dev/null +++ b/packages/editor/src/ui/light-editor/plugins/upload-image.tsx @@ -0,0 +1,138 @@ +import { UploadImage } from "@/types/upload-image"; +import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; + +const uploadKey = new PluginKey("upload-image"); + +const UploadImagesPlugin = () => + new Plugin({ + key: uploadKey, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(uploadKey); + if (action && action.add) { + const { id, pos, src } = action.add; + + const placeholder = document.createElement("div"); + placeholder.setAttribute("class", "img-placeholder"); + const image = document.createElement("img"); + image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300"); + image.src = src; + placeholder.appendChild(image); + const deco = Decoration.widget(pos + 1, placeholder, { + id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); + +export default UploadImagesPlugin; + +function findPlaceholder(state: EditorState, id: {}) { + const decos = uploadKey.getState(state); + const found = decos.find( + undefined, + undefined, + (spec: { id: number | undefined }) => spec.id == id + ); + return found.length ? found[0].from : null; +} + +export async function startImageUpload( + file: File, + view: EditorView, + pos: number, + workspaceSlug: string, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +) { + if (!file.type.includes("image/")) { + return; + } + + const id = {}; + + const tr = view.state.tr; + if (!tr.selection.empty) tr.deleteSelection(); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + tr.setMeta(uploadKey, { + add: { + id, + pos, + src: reader.result, + }, + }); + view.dispatch(tr); + }; + + if (!workspaceSlug) { + return; + } + setIsSubmitting?.("submitting"); + const src = await UploadImageHandler(file, workspaceSlug, uploadFile); + const { schema } = view.state; + pos = findPlaceholder(view.state, id); + + if (pos == null) return; + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + const transaction = view.state.tr + .replaceWith(pos, pos, node) + .setMeta(uploadKey, { remove: { id } }); + view.dispatch(transaction); +} + +const UploadImageHandler = (file: File, workspaceSlug: string, + uploadFile: UploadImage +): Promise => { + if (!workspaceSlug) { + return Promise.reject("Workspace slug is missing"); + } + try { + const formData = new FormData(); + formData.append("asset", file); + formData.append("attributes", JSON.stringify({})); + + return new Promise(async (resolve, reject) => { + try { + const imageUrl = await uploadFile(workspaceSlug, formData) + .then((response: { asset: string }) => response.asset); + + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(imageUrl); + }; + } catch (error) { + if (error instanceof Error) { + console.log(error.message); + } + reject(error); + } + }); + } catch (error) { + if (error instanceof Error) { + console.log(error.message); + } + return Promise.reject(error); + } +}; diff --git a/packages/editor/src/ui/light-editor/props.tsx b/packages/editor/src/ui/light-editor/props.tsx new file mode 100644 index 000000000..ff5b2f11b --- /dev/null +++ b/packages/editor/src/ui/light-editor/props.tsx @@ -0,0 +1,71 @@ +import { EditorProps } from "@tiptap/pm/view"; +import { findTableAncestor } from "@/ui/editor/menus/table-menu"; +import { startImageUpload } from "@/ui/editor/plugins/upload-image"; +import { UploadImage } from "@/types/upload-image"; + +export function TiptapEditorProps( + workspaceSlug: string, + uploadFile: UploadImage, + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void +): EditorProps { + return { + attributes: { + class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, + }, + handleDOMEvents: { + keydown: (_view, event) => { + // prevent default event listeners from firing when slash command is active + if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { + const slashCommand = document.querySelector("#slash-command"); + if (slashCommand) { + return true; + } + } + }, + }, + handlePaste: (view, event) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } + if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { + event.preventDefault(); + const file = event.clipboardData.files[0]; + const pos = view.state.selection.from; + startImageUpload(file, view, pos, workspaceSlug, uploadFile, setIsSubmitting); + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } + if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + // here we deduct 1 from the pos or else the image will create an extra node + if (coordinates) { + startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, uploadFile, setIsSubmitting); + } + return true; + } + return false; + }, + }; +} diff --git a/packages/editor/tsup.config.ts b/packages/editor/tsup.config.ts index 236e425ed..5e89e04af 100644 --- a/packages/editor/tsup.config.ts +++ b/packages/editor/tsup.config.ts @@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({ entry: ["src/index.ts"], format: ["cjs", "esm"], dts: true, - clean: true, + clean: false, external: ["react"], injectStyle: true, ...options, diff --git a/web/components/issues/comment/add-comment.tsx b/web/components/issues/comment/add-comment.tsx index ef1d4cec7..78b38e1d3 100644 --- a/web/components/issues/comment/add-comment.tsx +++ b/web/components/issues/comment/add-comment.tsx @@ -66,38 +66,6 @@ export const AddComment: React.FC = ({
- {showAccessSpecifier && ( -
- ( -
- {commentAccess.map((access) => ( - - - - ))} -
- )} - /> -
- )} = ({ /> )} /> + {showAccessSpecifier && ( +
+ ( +
+ {commentAccess.map((access) => ( + + + + ))} +
+ )} + /> +
+ )}
diff --git a/web/components/web-view/add-comment.tsx b/web/components/web-view/add-comment.tsx index b8e67be2b..d315c3072 100644 --- a/web/components/web-view/add-comment.tsx +++ b/web/components/web-view/add-comment.tsx @@ -74,8 +74,24 @@ export const AddComment: React.FC = ({ disabled = false, onSubmit }) => { return (
+ ( +

" : value} + customClassName="p-3 min-h-[100px] shadow-sm" + debouncedUpdatesEnabled={false} + onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)} + /> + )} + /> {showAccessSpecifier && ( -
+
= ({ disabled = false, onSubmit }) => { />
)} - ( -

" : value} - customClassName="p-3 min-h-[100px] shadow-sm" - debouncedUpdatesEnabled={false} - onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)} - /> - )} - />
diff --git a/yarn.lock b/yarn.lock index 8b7bf7f0a..9ec69da91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1892,6 +1892,13 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-compose-refs@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" + integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-context@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.0.tgz#f38e30c5859a9fb5e9aa9a9da452ee3ed9e0aee0" @@ -1990,6 +1997,14 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.0" +"@radix-ui/react-slot@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" + integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-use-callback-ref@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz#9e7b8b6b4946fe3cbe8f748c82a2cce54e7b6a90" @@ -2793,7 +2808,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@18.0.15", "@types/react@18.0.28", "@types/react@18.2.0", "@types/react@^18.0.17": +"@types/react@*", "@types/react@^18.0.17": version "18.2.0" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.0.tgz#15cda145354accfc09a18d2f2305f9fc099ada21" integrity sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA== @@ -2802,6 +2817,24 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@18.0.15": + version "18.0.15" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" + integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/react@18.0.28": + version "18.0.28" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.28.tgz#accaeb8b86f4908057ad629a26635fe641480065" + integrity sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/reactcss@*": version "1.2.6" resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.6.tgz#133c1e7e896f2726370d1d5a26bf06a30a038bcc" @@ -3473,6 +3506,13 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +class-variance-authority@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.0.tgz#1c3134d634d80271b1837452b06d821915954522" + integrity sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A== + dependencies: + clsx "2.0.0" + classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" @@ -3490,16 +3530,16 @@ client-only@^0.0.1: resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== +clsx@2.0.0, clsx@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" + integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== + clsx@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" - integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== - cmdk@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.0.tgz#53c52d56d8776c8bb8ced1055b5054100c388f7c"